diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 738ec9de95a..f7e6e595092 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -36,6 +36,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. - Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. - When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. +- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. @@ -45,8 +46,10 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. - Derived atom names read as business facts. Write atom names read as user or workflow commands. - UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. -- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. -- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow. +- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. +- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them. +- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need. +- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks. - `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. - Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient. @@ -108,7 +111,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. - Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. -- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface. +- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface. ## You Might Not Need An Effect diff --git a/api/.env.example b/api/.env.example index 8a2af53c6e7..3aa107130f9 100644 --- a/api/.env.example +++ b/api/.env.example @@ -768,7 +768,6 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub # Whether to use Redis cluster mode while use redis as event bus. # It's highly recommended to enable this for large deployments. EVENT_BUS_REDIS_USE_CLUSTERS=false -EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 # Whether to Enable human input timeout check task ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py index d465f2e93c3..0a166818b36 100644 --- a/api/configs/middleware/cache/redis_pubsub_config.py +++ b/api/configs/middleware/cache/redis_pubsub_config.py @@ -2,7 +2,6 @@ from typing import Literal, Protocol, cast from urllib.parse import quote_plus, urlunparse from pydantic import AliasChoices, Field -from pydantic.types import NonNegativeInt from pydantic_settings import BaseSettings @@ -71,24 +70,6 @@ class RedisPubSubConfig(BaseSettings): default=600, ) - PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field( - validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"), - description=( - "Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to " - "finish before returning. Bounds the tail latency between a terminal event being delivered to " - "an SSE client and the response stream actually closing.\n\n" - "The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout " - "for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for " - "the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() " - "return promptly while the daemon listener thread cleans itself up on the next poll " - "boundary - safe because the listener holds no critical state and exits within one poll " - "window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up " - "and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n" - "Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS." - ), - default=2000, - ) - def _build_default_pubsub_url(self) -> str: defaults = _redis_defaults(self) if not defaults.REDIS_HOST or not defaults.REDIS_PORT: diff --git a/api/controllers/common/app_access.py b/api/controllers/common/app_access.py new file mode 100644 index 00000000000..863b69d2339 --- /dev/null +++ b/api/controllers/common/app_access.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from services.enterprise import rbac_service as enterprise_rbac_service + +if TYPE_CHECKING: + from services.app_service import AppListBaseParams + from services.enterprise.rbac_service import MyPermissionsResponse + +# Permission keys (dot-notation, from MyPermissionsResponse) that grant +# list/preview access to an app. Keep this the single source of truth for both +# the console and OpenAPI app-list endpoints. +APP_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"app.preview", "app.acl.preview", "app.full_access"}) + +# Workspace permission key that lets a caller see apps they maintain even when +# those apps are not in their preview whitelist. +_MANAGE_OWN_APPS_PERMISSION_KEY = "app.create_and_management" + + +def has_app_list_permission(permission_keys: Sequence[str]) -> bool: + """Return True if any of ``permission_keys`` grants app list/preview access.""" + return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys) + + +@dataclass(frozen=True) +class AppAccessFilter: + """Resolved RBAC visibility for app list/read endpoints. + + ``accessible_app_ids`` of ``None`` means the caller can see every app in the + workspace (unrestricted). Otherwise it is the exact set of app ids the + caller may preview; combined with ``can_manage_own_apps`` it also covers + apps the caller maintains. + """ + + accessible_app_ids: set[str] | None + can_manage_own_apps: bool + + @classmethod + def unrestricted(cls) -> AppAccessFilter: + """Filter that imposes no restriction (RBAC disabled / not applicable).""" + return cls(accessible_app_ids=None, can_manage_own_apps=False) + + def is_app_accessible(self, app_id: str, maintainer: str | None, account_id: str) -> bool: + """Whether a single app is visible to the caller under this filter. + + Mirrors the service-layer query gate: an app is visible when the filter + is unrestricted, the app id is whitelisted, or the caller maintains it + and holds ``app.create_and_management``. + """ + if self.accessible_app_ids is None: + return True + if app_id in self.accessible_app_ids: + return True + return self.can_manage_own_apps and maintainer is not None and maintainer == account_id + + def apply_to_params(self, params: AppListBaseParams) -> None: + if self.accessible_app_ids is None: + return + params.accessible_app_ids = sorted(self.accessible_app_ids) + params.include_own_apps = self.can_manage_own_apps + + +def resolve_app_access_filter( + tenant_id: str, + account_id: str, + *, + permissions: MyPermissionsResponse | None = None, +) -> AppAccessFilter: + """Compute the RBAC app-access filter for ``account_id`` in ``tenant_id``. + + Pass ``permissions`` when the caller has already fetched the snapshot (the + console controller reuses it for per-app permission keys) to avoid a second + inner-API round trip; otherwise it is fetched here. + """ + if permissions is None: + permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id) + whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources(tenant_id, account_id) + + can_manage_own_apps = _MANAGE_OWN_APPS_PERMISSION_KEY in permissions.workspace.permission_keys + has_default_preview = has_app_list_permission(permissions.app.default_permission_keys) or has_app_list_permission( + permissions.workspace.permission_keys + ) + + permission_app_ids: set[str] | None = None + if not has_default_preview: + # Collect apps the caller can preview via per-app permission overrides. + permission_app_ids = { + override.resource_id + for override in permissions.app.overrides + if has_app_list_permission(override.permission_keys) + } + + accessible_app_ids: set[str] | None + if getattr(whitelist_scope, "unrestricted", False): + accessible_app_ids = permission_app_ids + else: + accessible_app_ids = set(whitelist_scope.resource_ids) + if permission_app_ids is not None: + accessible_app_ids |= permission_app_ids + elif has_default_preview: + # Default preview overrides the whitelist restriction. + accessible_app_ids = None + + return AppAccessFilter(accessible_app_ids=accessible_app_ids, can_manage_own_apps=can_manage_own_apps) diff --git a/api/controllers/common/wraps.py b/api/controllers/common/wraps.py index 7e39b4f37cd..29b1fc44e5d 100644 --- a/api/controllers/common/wraps.py +++ b/api/controllers/common/wraps.py @@ -1,23 +1,3 @@ -"""Shared decorator utilities for Dify controller layers. - -This module provides decorators that are not tied to any single API group (e.g. -console, inner, service). Currently it exposes the RBAC permission gate, which -can be applied to any blueprint. - -Key exports ------------ -``rbac_permission_required`` – decorator that enforces enterprise RBAC access - control. When ``RBAC_ENABLED`` is ``False`` it is a no-op. - -``RBACPermission``, ``RBACResourceScope`` – re-exported from ``core.rbac`` so - callers only need a single import site. - -Private helpers ---------------- -``_extract_resource_id``, ``_is_resource_owned_by_current_user`` – kept module- - private but accessible via the module namespace for unit-test patching. -""" - from collections.abc import Callable from functools import wraps @@ -32,7 +12,57 @@ from models.dataset import Dataset from models.model import App from services.enterprise.rbac_service import RBACService -__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] +__all__ = ["RBACPermission", "RBACResourceScope", "enforce_rbac_access", "rbac_permission_required"] + + +def enforce_rbac_access( + *, + tenant_id: str, + account_id: str, + resource_type: RBACResourceScope, + scene: RBACPermission, + resource_required: bool = True, + path_args: dict[str, object] | None = None, +) -> None: + """Enforce enterprise RBAC for an explicit account/tenant pair. + + This is the flask-login-independent core of the RBAC gate so it can run + inside request-handling layers that resolve the caller themselves (e.g. the + openapi auth pipeline, which has the account on ``AuthData`` before + flask-login is mounted). + + No-op when ``RBAC_ENABLED`` is ``False``. For resource-scoped checks the + resource ID is taken from ``path_args`` merged with ``request.view_args``; + resource ownership short-circuits the check. Raises ``Forbidden`` when + access is denied. For workspace-level checks pass ``resource_required=False`` + so the RBAC request omits ``resource_id``. + + Args: + tenant_id: The tenant the access is evaluated against. + account_id: The account requesting access. + resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace). + scene: The :class:`RBACPermission` permission point, e.g. ``RBACPermission.APP_DELETE``. + resource_required: Whether a concrete resource ID is required. + path_args: Extra path arguments to merge with ``request.view_args``. + """ + if not dify_config.RBAC_ENABLED: + return + + check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type + resource_id = None + if resource_required and check_resource_type: + resource_id = _extract_resource_id(resource_type, path_args) + if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id): + return + allowed = RBACService.CheckAccess.check( + tenant_id, + account_id, + scene=scene, + resource_type=check_resource_type, + resource_id=resource_id, + ) + if not allowed: + raise Forbidden() def rbac_permission_required[**P, R]( @@ -41,14 +71,12 @@ def rbac_permission_required[**P, R]( *, resource_required: bool = True, ) -> Callable[[Callable[P, R]], Callable[P, R]]: - """Check enterprise RBAC permissions for the current user. + """Check enterprise RBAC permissions for the current flask-login user. When ``RBAC_ENABLED`` is ``False`` the decorator is a no-op and the - request passes through unchanged. When enabled it extracts the resource ID - from ``request.view_args`` for resource-scoped checks, calls the RBAC - service ``check-access`` endpoint, and raises ``Forbidden`` if the access - is denied. For workspace-level checks, set ``resource_required=False`` so - the RBAC request omits ``resource_id``. + request passes through unchanged. When enabled it resolves the current + account/tenant and delegates to :func:`enforce_rbac_access`, raising + ``Forbidden`` if access is denied. Args: resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace). @@ -63,23 +91,14 @@ def rbac_permission_required[**P, R]( return view(*args, **kwargs) current_user, current_tenant_id = current_account_with_tenant() - check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type - resource_id = None - if resource_required and check_resource_type: - resource_id = _extract_resource_id(resource_type, kwargs) - if _is_resource_owned_by_current_user(current_tenant_id, current_user.id, resource_type, resource_id): - return view(*args, **kwargs) - allowed = RBACService.CheckAccess.check( - current_tenant_id, - current_user.id, + enforce_rbac_access( + tenant_id=current_tenant_id, + account_id=current_user.id, + resource_type=resource_type, scene=scene, - resource_type=check_resource_type, - resource_id=resource_id, + resource_required=resource_required, + path_args=kwargs, ) - - if not allowed: - raise Forbidden() - return view(*args, **kwargs) return decorated diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index d4546ac88bf..96bce6763f5 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -3,10 +3,12 @@ from uuid import UUID from flask import abort, request from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, field_validator +from sqlalchemy import func, select from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model +from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource from controllers.console.app.app import ( AppDetailWithSite as GenericAppDetailWithSite, ) @@ -25,9 +27,13 @@ from controllers.console.app.app import ( UpdateAppPayload as GenericUpdateAppPayload, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, enterprise_license_required, + is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -36,6 +42,7 @@ from extensions.ext_database import db from fields.agent_fields import ( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, + AgentConfigSnapshotRestoreResponse, AgentInviteOptionsResponse, AgentLogListResponse, AgentLogMessageListResponse, @@ -48,7 +55,8 @@ from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account -from models.model import IconType +from models.enums import ApiTokenType +from models.model import ApiToken, App, IconType from services.agent.errors import AgentNotFoundError from services.agent.observability_service import ( AgentLogQueryParams, @@ -102,6 +110,27 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload): return role +class AgentApiStatusPayload(BaseModel): + enable_api: bool = Field(..., description="Enable or disable Agent service API") + + +class AgentApiAccessResponse(BaseModel): + enabled: bool + service_api_base_url: str + streaming_only: bool = True + chat_endpoint: str + stop_endpoint: str + conversations_endpoint: str + messages_endpoint: str + files_upload_endpoint: str + parameters_endpoint: str + info_endpoint: str + meta_endpoint: str + api_rpm: int + api_rph: int + api_key_count: int + + class AgentAppPublishedReferenceResponse(BaseModel): app_id: str app_name: str @@ -185,6 +214,7 @@ class AgentStatisticsQuery(BaseModel): class AgentAppPartial(GenericAppPartial): app_id: str | None = None + debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False published_reference_count: int = 0 @@ -193,6 +223,7 @@ class AgentAppPartial(GenericAppPartial): class AgentAppDetailWithSite(GenericAppDetailWithSite): app_id: str | None = None + debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -207,6 +238,7 @@ register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, + AgentApiStatusPayload, CopyAppPayload, AgentInviteOptionsQuery, AgentLogsQuery, @@ -218,11 +250,13 @@ register_schema_models( register_response_schema_models( console_ns, AgentAppPagination, + AgentApiAccessResponse, AgentAppPublishedReferenceResponse, AgentAppDetailWithSite, AgentAppPartial, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, + AgentConfigSnapshotRestoreResponse, AgentInviteOptionsResponse, AgentLogListResponse, AgentLogMessageListResponse, @@ -237,7 +271,7 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -def _serialize_agent_app_detail(app_model) -> dict: +def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: """Serialize an Agent App detail using roster-only DTOs. `/agent` responses are roster-shaped rather than raw app-shaped: `id` @@ -260,6 +294,11 @@ def _serialize_agent_app_detail(app_model) -> dict: payload.pop("bound_agent_id", None) payload["app_id"] = str(app_model.id) payload["id"] = agent.id + payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id( + tenant_id=app_model.tenant_id, + agent_id=agent.id, + account_id=current_user.id, + ) payload["role"] = agent.role or "" payload["active_config_is_published"] = roster_service.active_config_is_published( tenant_id=app_model.tenant_id, @@ -268,7 +307,7 @@ def _serialize_agent_app_detail(app_model) -> dict: return payload -def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: +def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_user: Account) -> dict: """Serialize Agent App lists with roster-shaped items. Each item starts from the shared App list shape, then drops @@ -291,6 +330,11 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents_by_app_id.values()], ) + debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id( + tenant_id=tenant_id, + agents=list(agents_by_app_id.values()), + account_id=current_user.id, + ) payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") for item in payload["data"]: app_id = item["id"] @@ -299,6 +343,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: if agent: item["app_id"] = app_id item["id"] = agent.id + item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id) item["role"] = agent.role or "" item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) published_references = published_references_by_agent_id.get(agent.id, []) @@ -323,6 +368,38 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) +def _agent_api_key_count(app_id: str) -> int: + return ( + db.session.scalar( + select(func.count(ApiToken.id)).where( + ApiToken.type == ApiTokenType.APP, + ApiToken.app_id == app_id, + ) + ) + or 0 + ) + + +def _serialize_agent_api_access(app_model: App) -> dict: + base_url = app_model.api_base_url + response = AgentApiAccessResponse( + enabled=bool(app_model.enable_api), + service_api_base_url=base_url, + chat_endpoint=f"{base_url}/chat-messages", + stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop", + conversations_endpoint=f"{base_url}/conversations", + messages_endpoint=f"{base_url}/messages", + files_upload_endpoint=f"{base_url}/files/upload", + parameters_endpoint=f"{base_url}/parameters", + info_endpoint=f"{base_url}/info", + meta_endpoint=f"{base_url}/meta", + api_rpm=app_model.api_rpm or 0, + api_rph=app_model.api_rph or 0, + api_key_count=_agent_api_key_count(str(app_model.id)), + ) + return response.model_dump(mode="json") + + def _agent_observability_service() -> AgentObservabilityService: return AgentObservabilityService(db.session) @@ -374,7 +451,11 @@ class AgentAppListApi(Resource): empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json") - return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) + return _serialize_agent_app_pagination( + app_pagination, + tenant_id=current_tenant_id, + current_user=current_user, + ) @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) @console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -399,7 +480,7 @@ class AgentAppListApi(Resource): ) app = AppService().create_app(current_tenant_id, params, current_user) - return _serialize_agent_app_detail(app), 201 + return _serialize_agent_app_detail(app, current_user=current_user), 201 @console_ns.route("/agent/") @@ -409,10 +490,11 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @enterprise_license_required + @with_current_user @with_current_tenant_id - def get(self, tenant_id: str, agent_id: UUID): + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _serialize_agent_app_detail(app_model) + return _serialize_agent_app_detail(app_model, current_user=current_user) @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) @console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -422,8 +504,9 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @edit_permission_required + @with_current_user @with_current_tenant_id - def put(self, tenant_id: str, agent_id: UUID): + def put(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppUpdatePayload.model_validate(console_ns.payload) args_dict: AppService.ArgsDict = { @@ -437,7 +520,7 @@ class AgentAppApi(Resource): "role": args.role, } updated = AppService().update_app(app_model, args_dict) - return _serialize_agent_app_detail(updated) + return _serialize_agent_app_detail(updated, current_user=current_user) @console_ns.response(204, "Agent app deleted successfully") @console_ns.response(403, "Insufficient permissions") @@ -476,7 +559,76 @@ class AgentAppCopyApi(Resource): icon=args.icon, icon_background=args.icon_background, ) - return _serialize_agent_app_detail(copied_app), 201 + return _serialize_agent_app_detail(copied_app, current_user=current_user), 201 + + +@console_ns.route("/agent//api-access") +class AgentApiAccessApi(Resource): + @console_ns.response(200, "Agent service API access", console_ns.models[AgentApiAccessResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _serialize_agent_api_access(app_model) + + +@console_ns.route("/agent//api-enable") +class AgentApiStatusApi(Resource): + @console_ns.expect(console_ns.models[AgentApiStatusPayload.__name__]) + @console_ns.response(200, "Agent service API status updated", console_ns.models[AgentApiAccessResponse.__name__]) + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @is_admin_or_owner_required + @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @with_current_tenant_id + def post(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + args = AgentApiStatusPayload.model_validate(console_ns.payload) + app_model = AppService().update_app_api_status(app_model, args.enable_api) + return _serialize_agent_api_access(app_model) + + +@console_ns.route("/agent//api-keys") +class AgentApiKeyListApi(BaseApiKeyListResource): + resource_type = ApiTokenType.APP + resource_model = App + resource_id_field = "app_id" + token_prefix = "app-" + + @console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__]) + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID) -> dict[str, object]: + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id)) + + @console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__]) + @console_ns.response(400, "Maximum keys exceeded") + @with_current_tenant_id + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + def post(self, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]: + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return dump_response(ApiKeyItem, self._create_api_key(str(app_model.id), tenant_id)), 201 + + +@console_ns.route("/agent//api-keys/") +class AgentApiKeyApi(BaseApiKeyResource): + resource_type = ApiTokenType.APP + resource_model = App + resource_id_field = "app_id" + + @console_ns.response(204, "Agent service API key deleted") + @with_current_user + @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, api_key_id: UUID) -> tuple[str, int]: + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user) + return "", 204 @console_ns.route("/agent/invite-options") @@ -649,3 +801,24 @@ class AgentRosterVersionDetailApi(Resource): version_id=str(version_id), ), ) + + +@console_ns.route("/agent//versions//restore") +class AgentRosterVersionRestoreApi(Resource): + @console_ns.response(200, "Agent version restored", console_ns.models[AgentConfigSnapshotRestoreResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID, version_id: UUID): + return dump_response( + AgentConfigSnapshotRestoreResponse, + _agent_roster_service().restore_agent_version( + tenant_id=tenant_id, + agent_id=str(agent_id), + version_id=str(version_id), + account_id=current_user.id, + ), + ) diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py index b8d1d487808..bd639955d9c 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -10,8 +10,12 @@ backend — drive data lives in the API's own DB/storage, served straight from from __future__ import annotations +import json +from collections.abc import Mapping +from typing import Any from uuid import UUID +from flask import Response from flask_restx import Resource from pydantic import BaseModel, Field @@ -49,6 +53,10 @@ class AgentDriveFileByAgentQuery(BaseModel): key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") +class AgentDriveSkillInspectQuery(BaseModel): + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + class AgentDriveItemResponse(ResponseModel): key: str size: int | None = None @@ -56,12 +64,63 @@ class AgentDriveItemResponse(ResponseModel): hash: str | None = None file_kind: str created_at: int | None = None + is_skill: bool | None = None + skill_metadata: str | None = None class AgentDriveListResponse(ResponseModel): items: list[AgentDriveItemResponse] = Field(default_factory=list) +class AgentDriveSkillItemResponse(ResponseModel): + path: str + skill_md_key: str + archive_key: str | None = None + name: str + description: str + size: int | None = None + mime_type: str | None = None + hash: str | None = None + created_at: int | None = None + + +class AgentDriveSkillListResponse(ResponseModel): + items: list[AgentDriveSkillItemResponse] = Field(default_factory=list) + + +class AgentDriveSkillFileResponse(ResponseModel): + path: str + name: str + type: str + drive_key: str | None = None + available_in_drive: bool + + +class AgentDriveSkillMarkdownResponse(ResponseModel): + key: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class AgentDriveSkillInspectResponse(ResponseModel): + path: str + skill_md_key: str + archive_key: str | None = None + name: str + description: str + size: int | None = None + mime_type: str | None = None + hash: str | None = None + created_at: int | None = None + source: str + files: list[AgentDriveSkillFileResponse] = Field(default_factory=list) + file_tree: list[dict[str, Any]] = Field(default_factory=list) + skill_md: AgentDriveSkillMarkdownResponse + warnings: list[str] = Field(default_factory=list) + + class AgentDrivePreviewResponse(ResponseModel): key: str size: int | None = None @@ -75,7 +134,12 @@ class AgentDriveDownloadResponse(ResponseModel): register_response_schema_models( - console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse + console_ns, + AgentDriveDownloadResponse, + AgentDriveListResponse, + AgentDrivePreviewResponse, + AgentDriveSkillInspectResponse, + AgentDriveSkillListResponse, ) @@ -96,6 +160,13 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]: return {"code": exc.code, "message": exc.message}, exc.status_code +def _json_response(data: Mapping[str, Any]): + return Response( + response=json.dumps(data, ensure_ascii=False, separators=(",", ":")), + content_type="application/json; charset=utf-8", + ) + + _WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] @@ -119,6 +190,49 @@ class AgentDriveListByAgentApi(Resource): return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} +@console_ns.route("/agent//drive/skills") +class AgentDriveSkillListByAgentApi(Resource): + @console_ns.doc("list_agent_drive_skills_by_agent") + @console_ns.doc(description="List drive-backed skills for an Agent App") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id)) + except AgentDriveError as exc: + return _handle(exc) + return {"items": items} + + +@console_ns.route("/agent//drive/skills//inspect") +class AgentDriveSkillInspectByAgentApi(Resource): + @console_ns.doc("inspect_agent_drive_skill_by_agent") + @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI") + @console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"}) + @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID, skill_path: str): + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + return _json_response( + AgentDriveService().inspect_skill( + tenant_id=tenant_id, + agent_id=str(agent_id), + skill_path=skill_path, + ) + ) + except AgentDriveError as exc: + return _handle(exc) + + @console_ns.route("/agent//drive/files/preview") class AgentDrivePreviewByAgentApi(Resource): @console_ns.doc("preview_agent_drive_file_by_agent") @@ -182,6 +296,61 @@ class AgentDriveListApi(Resource): return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} +@console_ns.route("/apps//agent/drive/skills") +class AgentDriveSkillListApi(Resource): + @console_ns.doc("list_agent_drive_skills") + @console_ns.doc(description="List drive-backed skills for the bound agent") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)}) + @console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveListQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id) + except AgentDriveError as exc: + return _handle(exc) + return {"items": items} + + +@console_ns.route("/apps//agent/drive/skills//inspect") +class AgentDriveSkillInspectApi(Resource): + @console_ns.doc("inspect_agent_drive_skill") + @console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI") + @console_ns.doc( + params={ + "app_id": "Application ID", + "skill_path": "Skill path/slug, e.g. tender-analyzer", + **query_params_from_model(AgentDriveSkillInspectQuery), + } + ) + @console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_WORKFLOW_APP_MODES) + def get(self, app_model: App, skill_path: str): + query = query_params_from_request(AgentDriveSkillInspectQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + return _json_response( + AgentDriveService().inspect_skill( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + skill_path=skill_path, + ) + ) + except AgentDriveError as exc: + return _handle(exc) + + @console_ns.route("/apps//agent/drive/files/preview") class AgentDrivePreviewApi(Resource): @console_ns.doc("preview_agent_drive_file") @@ -232,4 +401,8 @@ __all__ = [ "AgentDriveListByAgentApi", "AgentDrivePreviewApi", "AgentDrivePreviewByAgentApi", + "AgentDriveSkillInspectApi", + "AgentDriveSkillInspectByAgentApi", + "AgentDriveSkillListApi", + "AgentDriveSkillListByAgentApi", ] diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index cd8d9ff3785..07d71d4225a 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -14,6 +14,7 @@ from werkzeug.datastructures import MultiDict from werkzeug.exceptions import BadRequest, NotFound from configs import dify_config +from controllers.common.app_access import resolve_app_access_filter from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse from controllers.common.helpers import FileInfo from controllers.common.schema import ( @@ -78,7 +79,6 @@ _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] DEFAULT_APP_LIST_MODE: AppListMode = "all" -APP_LIST_PERMISSION_KEYS = frozenset({"app.preview", "app.acl.preview", "app.full_access"}) class AppListBaseQuery(BaseModel): @@ -167,10 +167,6 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, return normalized -def _has_app_list_permission(permission_keys: Sequence[str]) -> bool: - return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys) - - class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -612,38 +608,12 @@ class AppListApi(Resource): current_user_id, ) if dify_config.RBAC_ENABLED: - whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources( + access_filter = resolve_app_access_filter( str(current_tenant_id), current_user_id, + permissions=permissions, ) - can_manage_own_apps = "app.create_and_management" in permissions.workspace.permission_keys - has_default_preview = _has_app_list_permission( - permissions.app.default_permission_keys - ) or _has_app_list_permission(permissions.workspace.permission_keys) - permission_app_ids: set[str] | None = None - if not has_default_preview: - permission_app_ids = { - override.resource_id - for override in permissions.app.overrides - if _has_app_list_permission(override.permission_keys) - } - - if getattr(whitelist_scope, "unrestricted", False): - accessible_app_ids = permission_app_ids - else: - accessible_app_ids = set(whitelist_scope.resource_ids) - if permission_app_ids is not None: - accessible_app_ids |= permission_app_ids - elif has_default_preview: - accessible_app_ids = None - - if accessible_app_ids: - params.accessible_app_ids = sorted(accessible_app_ids) - params.include_own_apps = can_manage_own_apps - elif accessible_app_ids is not None and can_manage_own_apps: - params.is_created_by_me = True - elif accessible_app_ids is not None: - params.accessible_app_ids = [] + access_filter.apply_to_params(params) # get app list app_service = AppService() diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 62b95ad22e4..545fad34cde 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -40,12 +40,15 @@ from core.errors.error import ( QuotaExceededError, ) from core.helper.trace_id_helper import get_external_trace_id +from extensions.ext_database import db from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import uuid_value from libs.login import login_required from models import Account from models.model import App, AppMode +from services.agent.errors import AgentNotFoundError +from services.agent.roster_service import AgentRosterService from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError @@ -191,10 +194,11 @@ class ChatMessageApi(Resource): @account_initialization_required @edit_permission_required @with_current_user + @with_current_tenant_id @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) - def post(self, current_user: Account, app_model: App): - return _create_chat_message(current_user=current_user, app_model=app_model) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): + return _create_chat_message(current_tenant_id=current_tenant_id, current_user=current_user, app_model=app_model) @console_ns.route("/agent//chat-messages") @@ -215,7 +219,12 @@ class AgentChatMessageApi(Resource): @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) - return _create_chat_message(current_user=current_user, app_model=app_model) + return _create_chat_message( + current_tenant_id=current_tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=str(agent_id), + ) @console_ns.route("/apps//chat-messages//stop") @@ -249,11 +258,45 @@ class AgentChatMessageStopApi(Resource): return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) -def _create_chat_message(*, current_user: Account, app_model: App): +def _resolve_current_user_agent_debug_conversation_id( + *, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None +) -> str: + roster_service = AgentRosterService(db.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, + ) + + agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id)) + if agent is None: + raise AgentNotFoundError() + 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, + ) + + +def _create_chat_message( + *, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None +): raw_payload = console_ns.payload or {} args_model = ChatMessagePayload.model_validate(raw_payload) args = args_model.model_dump(exclude_none=True, by_alias=True) + if AppMode.value_of(app_model.mode) == AppMode.AGENT: + debug_conversation_id = _resolve_current_user_agent_debug_conversation_id( + current_tenant_id=current_tenant_id or app_model.tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=agent_id, + ) + if args_model.conversation_id and args_model.conversation_id != debug_conversation_id: + raise NotFound("Conversation Not Exists.") + args["conversation_id"] = debug_conversation_id + streaming = _resolve_debugger_chat_streaming( app_mode=AppMode.value_of(app_model.mode), response_mode=args_model.response_mode, diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 9944f02207f..726bd94cd7e 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -53,6 +53,7 @@ from libs.login import login_required from models.account import Account from models.enums import FeedbackFromSource, FeedbackRating from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback +from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError from services.message_service import MessageService, attach_message_extra_contents @@ -186,10 +187,11 @@ class ChatMessageListApi(Resource): @account_initialization_required @setup_required @edit_permission_required + @with_current_user @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) - def get(self, app_model: App): - return _list_chat_messages(app_model=app_model) + def get(self, current_user: Account, app_model: App): + return _list_chat_messages(app_model=app_model, current_user=current_user) @console_ns.route("/agent//chat-messages") @@ -205,10 +207,11 @@ class AgentChatMessageListApi(Resource): @setup_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @with_current_user @with_current_tenant_id - def get(self, current_tenant_id: str, agent_id: UUID): + def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) - return _list_chat_messages(app_model=app_model) + return _list_chat_messages(app_model=app_model, current_user=current_user) @console_ns.route("/apps//feedbacks") @@ -390,14 +393,24 @@ class AgentMessageApi(Resource): return _get_message_detail(app_model=app_model, message_id=message_id) -def _list_chat_messages(*, app_model: App): +def _list_chat_messages(*, app_model: App, current_user: Account | None = None): args = ChatMessagesQuery.model_validate(request.args.to_dict()) - conversation = db.session.scalar( - select(Conversation) - .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) - .limit(1) - ) + if AppMode.value_of(app_model.mode) == AppMode.AGENT and current_user is not None: + try: + conversation = ConversationService.get_conversation( + app_model=app_model, + conversation_id=args.conversation_id, + user=current_user, + ) + except ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + else: + conversation = db.session.scalar( + select(Conversation) + .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) + .limit(1) + ) if not conversation: raise NotFound("Conversation Not Exists.") diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index a9c97401105..1de206c73db 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -83,7 +83,7 @@ class ApiKeyAuthDataSourceBinding(Resource): @login_required @account_initialization_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False) @console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__]) @with_current_tenant_id def post(self, current_tenant_id: str): diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index c5ca1d155de..a575760ee19 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -222,7 +222,7 @@ class DatasourceAuth(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_CREATE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/workspace/__init__.py b/api/controllers/console/workspace/__init__.py index 59dd29fdace..13bc98c8047 100644 --- a/api/controllers/console/workspace/__init__.py +++ b/api/controllers/console/workspace/__init__.py @@ -5,6 +5,7 @@ from sqlalchemy import select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden +from configs import dify_config from extensions.ext_database import db from libs.login import current_account_with_tenant from models.account import TenantPluginPermission @@ -17,6 +18,9 @@ def plugin_permission_required( def interceptor[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if dify_config.RBAC_ENABLED: + return view(*args, **kwargs) + current_user, current_tenant_id = current_account_with_tenant() user = current_user tenant_id = current_tenant_id diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 8fda67f4ef8..3ce7211703e 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -169,7 +169,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -244,7 +244,7 @@ class ModelProviderCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -326,7 +326,7 @@ class PreferredProviderTypeUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index e82c0fbc2db..1da72ef4362 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -395,7 +395,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -481,7 +481,7 @@ class ModelProviderModelCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d599466002d..e768bb5acde 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -469,6 +469,7 @@ class PluginDebuggingKeyApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DEBUG, resource_required=False) @plugin_permission_required(debug_required=True) @with_current_tenant_id def get(self, tenant_id: str): @@ -614,6 +615,7 @@ class PluginUploadFromPkgApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -634,6 +636,7 @@ class PluginUploadFromGithubApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -653,6 +656,7 @@ class PluginUploadFromBundleApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -673,6 +677,7 @@ class PluginInstallFromPkgApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -693,6 +698,7 @@ class PluginInstallFromGithubApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -719,6 +725,7 @@ class PluginInstallFromMarketplaceApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -739,6 +746,7 @@ class PluginFetchMarketplacePkgApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def get(self, tenant_id: str): @@ -764,6 +772,7 @@ class PluginFetchManifestApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def get(self, tenant_id: str): @@ -784,6 +793,7 @@ class PluginFetchInstallTasksApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def get(self, tenant_id: str): @@ -801,6 +811,7 @@ class PluginFetchInstallTaskApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def get(self, tenant_id: str, task_id: str): @@ -816,6 +827,7 @@ class PluginDeleteInstallTaskApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str, task_id: str): @@ -831,6 +843,7 @@ class PluginDeleteAllInstallTaskItemsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -846,6 +859,7 @@ class PluginDeleteInstallTaskItemApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str, task_id: str, identifier: str): @@ -862,6 +876,7 @@ class PluginUpgradeFromMarketplaceApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -884,6 +899,7 @@ class PluginUpgradeFromGithubApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -911,6 +927,7 @@ class PluginUninstallApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False) @plugin_permission_required(install_required=True) @with_current_tenant_id def post(self, tenant_id: str): @@ -1041,10 +1058,11 @@ class PluginChangeAutoUpgradeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @with_current_user @with_current_tenant_id def post(self, tenant_id: str, user: Account): - if not user.is_admin_or_owner: + if not dify_config.RBAC_ENABLED and not user.is_admin_or_owner: raise Forbidden() args = ParserAutoUpgradeChange.model_validate(console_ns.payload) @@ -1097,6 +1115,7 @@ class PluginAutoUpgradeExcludePluginApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @with_current_tenant_id def post(self, tenant_id: str): # exclude one single plugin diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py index 1b213a4f741..f672833061a 100644 --- a/api/controllers/console/workspace/rbac.py +++ b/api/controllers/console/workspace/rbac.py @@ -211,7 +211,7 @@ def _legacy_workspace_roles( name=role_name, description="", is_builtin=True, - permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]), + permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])), role_tag="owner" if role_name == "owner" else "", ) for role_name in ("owner", "admin", "editor", "normal", "dataset_operator") @@ -244,11 +244,6 @@ def _legacy_workspace_roles( ) -# --------------------------------------------------------------------------- -# Permission catalogs. -# --------------------------------------------------------------------------- - - @console_ns.route("/workspaces/current/rbac/role-permissions/catalog") class RBACWorkspaceCatalogApi(Resource): @login_required @@ -375,30 +370,6 @@ class RBACRoleCopyApi(Resource): return _dump(role), 201 -@console_ns.route("/workspaces/current/rbac/roles//members") -class RBACRoleMembersApi(Resource): - @login_required - @rbac_permission_required( - RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False - ) - @console_ns.response(200, "Success", console_ns.models[_RBACRoleAccountList.__name__]) - def get(self, role_id): - tenant_id, account_id = _current_ids() - return _dump( - svc.RBACService.Roles.members( - tenant_id, - account_id, - str(role_id), - options=_pagination_options(), - ) - ) - - -# --------------------------------------------------------------------------- -# Access policies (tenant-level permission sets). -# --------------------------------------------------------------------------- - - class _AccessPolicyCreateRequest(BaseModel): name: str resource_type: svc.RBACResourceType @@ -788,11 +759,6 @@ class RBACDatasetMemberBindingsApi(Resource): return {"result": "success"} -# --------------------------------------------------------------------------- -# Workspace-level access (Settings > Access Rules). -# --------------------------------------------------------------------------- - - @console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy") class RBACWorkspaceAppMatrixApi(Resource): @login_required diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 9a92571594c..4125e7d8de8 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -971,7 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @setup_required @login_required @is_admin_or_owner_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -1070,6 +1070,7 @@ class ToolProviderMCPApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id def post(self, tenant_id: str, user: Account): @@ -1125,6 +1126,7 @@ class ToolProviderMCPApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False) @with_current_tenant_id def put(self, current_tenant_id: str): payload = MCPProviderUpdatePayload.model_validate(console_ns.payload or {}) @@ -1178,6 +1180,7 @@ class ToolProviderMCPApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False) @with_current_tenant_id def delete(self, current_tenant_id: str): payload = MCPProviderDeletePayload.model_validate(console_ns.payload or {}) @@ -1196,6 +1199,7 @@ class ToolMCPAuthApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False) @with_current_tenant_id def post(self, tenant_id: str): payload = MCPAuthPayload.model_validate(console_ns.payload or {}) @@ -1300,6 +1304,7 @@ class ToolMCPUpdateApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False) @with_current_tenant_id def get(self, tenant_id: str, provider_id: str): with sessionmaker(db.engine).begin() as session: diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index e8406ea00cb..c11019cf627 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -31,7 +31,7 @@ from controllers.openapi._models import ( AppDslExportQuery, AppDslExportResponse, AppDslImportPayload, - AppInfoResponse, + AppInfo, AppListQuery, AppListResponse, AppListRow, @@ -62,7 +62,6 @@ from controllers.openapi._models import ( SessionListQuery, SessionListResponse, SessionRow, - TagItem, TaskStopResponse, UsageInfo, WorkflowRunData, @@ -96,12 +95,11 @@ register_response_schema_models( openapi_ns, ErrorBody, EventStreamResponse, - TagItem, UsageInfo, MessageMetadata, AppListRow, AppListResponse, - AppInfoResponse, + AppInfo, AppDescribeInfo, AppDescribeResponse, AppDslExportResponse, diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 38c068bd354..5e82c2614de 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -63,6 +63,8 @@ class OpenApiErrorCode(StrEnum): FILE_EXTENSION_BLOCKED = "file_extension_blocked" MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded" MEMBER_LICENSE_EXCEEDED = "member_license_exceeded" + HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found" + RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch" class ErrorDetail(BaseModel): @@ -239,3 +241,16 @@ class MemberLicenseExceeded(OpenApiError): # noqa: N818 error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED description = "Workspace member license capacity reached." hint = "Contact your workspace administrator to expand the license seat count." + + +class HumanInputFormNotFound(OpenApiError): # noqa: N818 + code = 404 + error_code = OpenApiErrorCode.HUMAN_INPUT_FORM_NOT_FOUND + description = "No human-input form matches this token. It may be wrong, expired, or already submitted." + + +class RecipientSurfaceMismatch(OpenApiError): # noqa: N818 + code = 403 + error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH + description = "This form's recipient can't be submitted via the OpenAPI surface." + hint = "Action it through its channel (web app or console)." diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 7c225c85f65..e846db3ea75 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -38,18 +38,12 @@ class PaginationEnvelope[T](BaseModel): return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) -class TagItem(BaseModel): - name: str - - class AppListRow(BaseModel): id: str name: str description: str | None = None mode: AppMode - tags: list[TagItem] = [] updated_at: str | None = None - created_by_name: str | None = None workspace_id: str | None = None workspace_name: str | None = None @@ -70,16 +64,14 @@ class PermittedExternalAppsListResponse(BaseModel): data: list[AppListRow] -class AppInfoResponse(BaseModel): +class AppInfo(BaseModel): id: str name: str description: str | None = None mode: str - author: str | None = None - tags: list[TagItem] = [] -class AppDescribeInfo(AppInfoResponse): +class AppDescribeInfo(AppInfo): updated_at: str | None = None service_api_enabled: bool is_agent: bool = False @@ -294,7 +286,6 @@ class AppListQuery(BaseModel): limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) mode: AppMode | None = None name: str | None = Field(None, max_length=200) - tag: str | None = Field(None, max_length=100) class AppRunRequest(BaseModel): diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 8a8c62f28ca..9b1abd24bac 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -5,11 +5,12 @@ from typing import cast from flask_restx import Resource from sqlalchemy.orm import Session +from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, RBACRequirement from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from models import Account, App @@ -37,6 +38,11 @@ class AppDslImportApi(Resource): scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), + rbac=RBACRequirement( + resource_type=RBACResourceScope.APP, + scene=RBACPermission.APP_IMPORT_EXPORT_DSL, + resource_required=False, + ), ) @returns(200, Import, "Import completed") @returns(202, Import, "Import pending confirmation") @@ -89,6 +95,11 @@ class AppDslImportConfirmApi(Resource): scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), + rbac=RBACRequirement( + resource_type=RBACResourceScope.APP, + scene=RBACPermission.APP_IMPORT_EXPORT_DSL, + resource_required=False, + ), ) @returns(200, Import, "Import confirmed") @returns(400, Import, "Import failed") @@ -125,6 +136,7 @@ class AppDslExportApi(Resource): scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_IMPORT_EXPORT_DSL), ) @accepts(query=AppDslExportQuery) @returns(200, AppDslExportResponse, "Export successful") @@ -155,6 +167,7 @@ class AppDslCheckDependenciesApi(Resource): scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_IMPORT_EXPORT_DSL), ) @returns(200, CheckDependenciesResult, "Dependencies checked") def get(self, app_id: str, *, auth_data: AuthData): diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 76ddd166596..7e77e3aa747 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -19,12 +19,13 @@ from werkzeug.exceptions import ( import services from controllers.common.fields import EventStreamResponse +from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi._audit import emit_app_run from controllers.openapi._contract import accepts, returns from controllers.openapi._models import AppRunRequest, TaskStopResponse from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, RBACRequirement from controllers.service_api.app.error import ( AppUnavailableError, CompletionRequestError, @@ -136,7 +137,10 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = { @openapi_ns.route("/apps//run") class AppRunApi(Resource): - @auth_router.guard(scope=Scope.APPS_RUN) + @auth_router.guard( + scope=Scope.APPS_RUN, + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), + ) @openapi_ns.response(200, "Run result (SSE stream)", openapi_ns.models[EventStreamResponse.__name__]) @accepts(body=AppRunRequest) def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest): @@ -167,7 +171,10 @@ class AppRunApi(Resource): @openapi_ns.route("/apps//tasks//stop") class AppRunTaskStopApi(Resource): - @auth_router.guard(scope=Scope.APPS_RUN) + @auth_router.guard( + scope=Scope.APPS_RUN, + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), + ) @returns(200, TaskStopResponse, description="Task stopped") def post(self, app_id: str, task_id: str, *, auth_data: AuthData): app_model, caller, caller_kind = auth_data.require_app_context() diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index c4796313c0b..c2626cd5d8c 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -8,7 +8,10 @@ from typing import Any, cast from flask_restx import Resource from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity +from configs import dify_config +from controllers.common.app_access import AppAccessFilter, resolve_app_access_filter from controllers.common.fields import Parameters +from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config @@ -19,18 +22,17 @@ from controllers.openapi._models import ( AppListQuery, AppListResponse, AppListRow, - TagItem, ) from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, RBACRequirement from controllers.service_api.app.error import AppUnavailableError from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from models import App +from models.model import AppMode from services.account_service import TenantService from services.app_service import AppListParams, AppService -from services.tag_service import TagService _ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"}) @@ -84,54 +86,55 @@ def parameters_payload(app: App) -> dict: return Parameters.model_validate(parameters).model_dump(mode="json") +def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse: + """Public projection of an app (name / params / input schema) — never internal config.""" + want_info = fields is None or "info" in fields + want_params = fields is None or "parameters" in fields + want_schema = fields is None or "input_schema" in fields + + info = ( + AppDescribeInfo( + id=str(app.id), + name=app.name, + mode=app.mode, + description=app.description, + updated_at=app.updated_at.isoformat() if app.updated_at else None, + service_api_enabled=bool(app.enable_api), + is_agent=app.mode in (AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT), + ) + if want_info + else None + ) + + parameters: dict[str, Any] | None = None + input_schema: dict[str, Any] | None = None + if want_params: + try: + parameters = parameters_payload(app) + except AppUnavailableError: + parameters = dict(_EMPTY_PARAMETERS) + if want_schema: + try: + input_schema = build_input_schema(app) + except AppUnavailableError: + input_schema = dict(EMPTY_INPUT_SCHEMA) + + return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema) + + @openapi_ns.route("/apps//describe") class AppDescribeApi(AppReadResource): - @auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) + @auth_router.guard( + scope=Scope.APPS_READ, + allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_VIEW_LAYOUT), + ) @returns(200, AppDescribeResponse, description="App description") @accepts(query=AppDescribeQuery) def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): # describe is UUID-only (workspace_id query param dropped in #37212). app = self._load(app_id) - - requested = query.fields - want_info = requested is None or "info" in requested - want_params = requested is None or "parameters" in requested - want_schema = requested is None or "input_schema" in requested - - info = ( - AppDescribeInfo( - id=str(app.id), - name=app.name, - mode=app.mode, - description=app.description, - tags=[TagItem(name=t.name) for t in app.tags], - author=app.author_name, - updated_at=app.updated_at.isoformat() if app.updated_at else None, - service_api_enabled=bool(app.enable_api), - is_agent=app.mode in ("agent-chat", "advanced-chat"), - ) - if want_info - else None - ) - - parameters: dict[str, Any] | None = None - input_schema: dict[str, Any] | None = None - if want_params: - try: - parameters = parameters_payload(app) - except AppUnavailableError: - parameters = dict(_EMPTY_PARAMETERS) - if want_schema: - try: - input_schema = build_input_schema(app) - except AppUnavailableError: - input_schema = dict(EMPTY_INPUT_SCHEMA) - - return AppDescribeResponse( - info=info, - parameters=parameters, - input_schema=input_schema, - ) + return build_app_describe_response(app, query.fields) @openapi_ns.route("/apps") @@ -152,45 +155,55 @@ class AppListApi(Resource): else: parsed_uuid = None + # Compute RBAC-accessible app IDs when RBAC is enabled and the caller is an account. + # ``None`` means unrestricted (caller can see all apps in the workspace); + # an empty set or list means the caller has no accessible apps. + # End-users bypass RBAC here — their access is controlled by scope upstream. + apply_rbac_filter = ( + dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None + ) + access_filter = AppAccessFilter.unrestricted() + if apply_rbac_filter: + access_filter = resolve_app_access_filter(workspace_id, str(auth_data.account_id)) + tenant_name: str | None = None if parsed_uuid is not None: app: App | None = AppService.get_visible_app_by_id(db.session, str(parsed_uuid)) if app is None or str(app.tenant_id) != workspace_id: return empty + # Apply RBAC visibility to the UUID fast-path the same way the service + # layer does for paginated queries (id in accessible set OR own app). + if apply_rbac_filter and not access_filter.is_app_accessible( + str(app.id), str(app.maintainer) if app.maintainer else None, str(auth_data.account_id) + ): + return empty tenant_name = TenantService.get_tenant_name(db.session, workspace_id) item = AppListRow( id=str(app.id), name=app.name, description=app.description, mode=app.mode, - tags=[TagItem(name=t.name) for t in app.tags], updated_at=app.updated_at.isoformat() if app.updated_at else None, - created_by_name=getattr(app, "author_name", None), workspace_id=str(workspace_id), workspace_name=tenant_name, ) env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item]) return env - tag_ids: list[str] | None = None - if query.tag: - tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session) - if not tags: - return empty - tag_ids = [tag.id for tag in tags] - params = AppListParams( page=query.page, limit=query.limit, mode=query.mode.value if query.mode else "all", # type:ignore name=query.name, - tag_ids=tag_ids, status="normal", # Visibility gate pushed into the query — pagination.total stays # consistent across pages because invisible rows never count. openapi_visible=True, ) + if apply_rbac_filter: + access_filter.apply_to_params(params) + pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, db.session) if pagination is None: return empty @@ -205,9 +218,7 @@ class AppListApi(Resource): name=r.name, description=r.description, mode=r.mode, - tags=[TagItem(name=t.name) for t in r.tags], updated_at=r.updated_at.isoformat() if r.updated_at else None, - created_by_name=getattr(r, "author_name", None), workspace_id=str(workspace_id), workspace_name=tenant_name, ) diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 0e889a2951c..9bc400e5cc7 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -8,14 +8,18 @@ EE blueprint chain so this module is unreachable there. from __future__ import annotations from flask_restx import Resource +from werkzeug.exceptions import NotFound from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns from controllers.openapi._models import ( + AppDescribeQuery, + AppDescribeResponse, AppListRow, PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, ) +from controllers.openapi.apps import build_app_describe_response from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData, Edition from extensions.ext_database import db @@ -67,9 +71,7 @@ class PermittedExternalAppsListApi(Resource): name=app.name, description=app.description, mode=app.mode, - tags=[], # tenant-scoped; not surfaced cross-tenant updated_at=app.updated_at.isoformat() if app.updated_at else None, - created_by_name=None, # cross-tenant author leak prevention workspace_id=str(app.tenant_id), workspace_name=tenant.name if tenant else None, ) @@ -82,3 +84,20 @@ class PermittedExternalAppsListApi(Resource): data=items, ) return env + + +@openapi_ns.route("/permitted-external-apps//describe") +class PermittedExternalAppDescribeApi(Resource): + @auth_router.guard( + scope=Scope.APPS_READ_PERMITTED_EXTERNAL, + allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), + edition=frozenset({Edition.EE}), + ) + @returns(200, AppDescribeResponse, description="Permitted external app description") + @accepts(query=AppDescribeQuery) + def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): + # App already loaded and ACL-checked by the external_sso pipeline; project it. + app = auth_data.app + if app is None: + raise NotFound("app not found") + return build_app_describe_response(app, query.fields) diff --git a/api/controllers/openapi/auth/composition.py b/api/controllers/openapi/auth/composition.py index 66f925c8cde..67f7001c080 100644 --- a/api/controllers/openapi/auth/composition.py +++ b/api/controllers/openapi/auth/composition.py @@ -3,9 +3,11 @@ from __future__ import annotations from controllers.openapi.auth.conditions import ( EDITION_EE, HAS_ALLOWED_ROLES, + HAS_RBAC, LOADED_APP_IS_PRIVATE, PATH_HAS_APP_ID, WEBAPP_AUTH_ENABLED, + WEBAPP_RUN_SCOPED, WORKSPACE_MEMBERSHIP_REQUIRED, WORKSPACE_SCOPED, ) @@ -25,6 +27,7 @@ from controllers.openapi.auth.verify import ( check_acl, check_app_api_enabled, check_private_app_permission, + check_rbac_permission, check_scope, check_workspace_member, check_workspace_mismatch, @@ -47,8 +50,9 @@ account_pipeline = AuthPipeline( When(WORKSPACE_SCOPED, then=check_workspace_member), When(PATH_HAS_APP_ID, then=check_workspace_mismatch), When(HAS_ALLOWED_ROLES, then=check_workspace_role), - When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED, then=check_acl), - When(EDITION_EE & LOADED_APP_IS_PRIVATE, then=check_private_app_permission), + When(HAS_RBAC, then=check_rbac_permission), + When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl), + When(EDITION_EE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission), ], ) diff --git a/api/controllers/openapi/auth/conditions.py b/api/controllers/openapi/auth/conditions.py index 5ad15e5e41c..73a767b8d8e 100644 --- a/api/controllers/openapi/auth/conditions.py +++ b/api/controllers/openapi/auth/conditions.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from controllers.openapi.auth.data import AuthData, Edition, RequestContext, current_edition -from libs.oauth_bearer import TokenType +from libs.oauth_bearer import Scope, TokenType from services.enterprise.enterprise_service import WebAppAccessMode from services.feature_service import FeatureService @@ -50,8 +50,11 @@ EDITION_SAAS = config_cond(lambda: current_edition() == Edition.SAAS) WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled) +WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN) + WORKSPACE_MEMBERSHIP_REQUIRED = request_cond(lambda ctx: ctx.workspace_membership) HAS_ALLOWED_ROLES = request_cond(lambda ctx: ctx.allowed_roles is not None) +HAS_RBAC = request_cond(lambda ctx: ctx.rbac is not None) # Caller must belong to the resolved tenant: either an app-scoped path (tenant # from the app) or an explicit workspace-membership path (tenant from request). diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index 76b0d90cb45..9aefef0061c 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field from werkzeug.exceptions import InternalServerError from configs import dify_config +from core.rbac import RBACPermission, RBACResourceScope from libs.oauth_bearer import Scope, TokenType from models.account import Account, Tenant, TenantAccountRole from models.model import App, EndUser @@ -35,6 +36,14 @@ class ExternalIdentity(BaseModel): issuer: str | None = None +class RBACRequirement(BaseModel): + model_config = ConfigDict(frozen=True) + + resource_type: RBACResourceScope + scene: RBACPermission + resource_required: bool = True + + class RequestContext(BaseModel): model_config = ConfigDict(frozen=True) @@ -43,6 +52,7 @@ class RequestContext(BaseModel): path_params: dict[str, str] workspace_membership: bool = False allowed_roles: frozenset[TenantAccountRole] | None = None + rbac: RBACRequirement | None = None class AuthData(BaseModel): @@ -59,6 +69,7 @@ class AuthData(BaseModel): path_params: dict[str, str] = Field(default_factory=dict) allowed_roles: frozenset[TenantAccountRole] | None = None + rbac: RBACRequirement | None = None app: App | None = None tenant: Tenant | None = None diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py index 488a971b1e0..3e0aca53d3c 100644 --- a/api/controllers/openapi/auth/pipeline.py +++ b/api/controllers/openapi/auth/pipeline.py @@ -21,6 +21,7 @@ from controllers.openapi.auth.data import ( AuthData, Edition, ExternalIdentity, + RBACRequirement, RequestContext, current_edition, ) @@ -59,6 +60,7 @@ class AuthPipeline: scope: Scope | None, workspace_membership: bool = False, allowed_roles: frozenset[TenantAccountRole] | None = None, + rbac: RBACRequirement | None = None, ) -> Any: req_ctx = RequestContext( token_type=identity.token_type, @@ -66,6 +68,7 @@ class AuthPipeline: path_params=dict(request.view_args or {}), workspace_membership=workspace_membership, allowed_roles=allowed_roles, + rbac=rbac, ) data = AuthData( @@ -77,6 +80,7 @@ class AuthPipeline: tenants=dict(identity.verified_tenants), required_scope=scope, allowed_roles=allowed_roles, + rbac=rbac, path_params=dict(req_ctx.path_params), external_identity=( ExternalIdentity(email=identity.subject_email, issuer=identity.subject_issuer) @@ -129,6 +133,7 @@ class PipelineRouter: edition: frozenset[Edition] | None = None, workspace_membership: bool = False, allowed_roles: frozenset[TenantAccountRole] | None = None, + rbac: RBACRequirement | None = None, ) -> Callable: return self._make_decorator( scope=scope, @@ -136,6 +141,7 @@ class PipelineRouter: edition=edition, workspace_membership=workspace_membership, allowed_roles=allowed_roles, + rbac=rbac, ) def guard_workspace( @@ -145,6 +151,7 @@ class PipelineRouter: allowed_token_types: frozenset[TokenType] | None = None, edition: frozenset[Edition] | None = None, allowed_roles: frozenset[TenantAccountRole] | None = None, + rbac: RBACRequirement | None = None, ) -> Callable: return self._make_decorator( scope=scope, @@ -152,6 +159,7 @@ class PipelineRouter: edition=edition, workspace_membership=True, allowed_roles=allowed_roles, + rbac=rbac, ) def _make_decorator( @@ -162,6 +170,7 @@ class PipelineRouter: edition: frozenset[Edition] | None, workspace_membership: bool, allowed_roles: frozenset[TenantAccountRole] | None, + rbac: RBACRequirement | None, ) -> Callable: def decorator(view: Callable) -> Callable: @wraps(view) @@ -175,6 +184,7 @@ class PipelineRouter: edition=edition, workspace_membership=workspace_membership, allowed_roles=allowed_roles, + rbac=rbac, ) return decorated @@ -192,6 +202,7 @@ class PipelineRouter: edition: frozenset[Edition] | None, workspace_membership: bool = False, allowed_roles: frozenset[TenantAccountRole] | None = None, + rbac: RBACRequirement | None = None, ) -> Any: # 404 not 403 — this edition doesn't expose the feature at all if edition is not None and current_edition() not in edition: @@ -235,6 +246,7 @@ class PipelineRouter: scope=scope, workspace_membership=workspace_membership, allowed_roles=allowed_roles, + rbac=rbac, ) diff --git a/api/controllers/openapi/auth/verify.py b/api/controllers/openapi/auth/verify.py index 8cd7a30f5e9..1323d142fb1 100644 --- a/api/controllers/openapi/auth/verify.py +++ b/api/controllers/openapi/auth/verify.py @@ -3,6 +3,8 @@ from __future__ import annotations from flask import request from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity +from configs import dify_config +from controllers.common.wraps import enforce_rbac_access from controllers.openapi.auth.data import AuthData from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType @@ -38,6 +40,9 @@ def check_workspace_mismatch(data: AuthData) -> None: def check_workspace_role(data: AuthData) -> None: + if dify_config.RBAC_ENABLED and data.rbac is not None: + # fine-grained permission check is performed by RBAC + return if data.allowed_roles is None: return if data.tenant_role is None: @@ -46,6 +51,27 @@ def check_workspace_role(data: AuthData) -> None: raise Forbidden("insufficient workspace role") +def check_rbac_permission(data: AuthData) -> None: + req = data.rbac + if req is None: + return + if not dify_config.RBAC_ENABLED: + return + # Only account callers are subject to RBAC; end_user access is scope-controlled. + if data.caller_kind != "account": + return + if data.account_id is None or data.tenant is None: + raise Forbidden("rbac context missing") + enforce_rbac_access( + tenant_id=str(data.tenant.id), + account_id=str(data.account_id), + resource_type=req.resource_type, + scene=req.scene, + resource_required=req.resource_required, + path_args=dict(data.path_params), + ) + + def check_app_api_enabled(data: AuthData) -> None: if data.app is None: return diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index 995315150cc..223f748613b 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -12,16 +12,21 @@ import logging from flask import Response from flask_restx import Resource -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.common.schema import register_schema_models +from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns +from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData -from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from controllers.openapi.auth.data import AuthData, RBACRequirement +from core.workflow.human_input_policy import ( + HumanInputSurface, + is_recipient_type_allowed_for_surface, +) from extensions.ext_database import db from libs.helper import to_timestamp from libs.oauth_bearer import Scope @@ -47,31 +52,37 @@ def _jsonify_form_definition(form) -> Response: def _ensure_form_belongs_to_app(form, app_model: App) -> None: if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id: - raise NotFound("Form not found") + raise HumanInputFormNotFound() def _ensure_form_is_allowed_for_openapi(form) -> None: if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI): - raise NotFound("Form not found") + raise RecipientSurfaceMismatch() @openapi_ns.route("/apps//form/human_input/") class OpenApiWorkflowHumanInputFormApi(Resource): @openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__]) - @auth_router.guard(scope=Scope.APPS_RUN) + @auth_router.guard( + scope=Scope.APPS_RUN, + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), + ) def get(self, app_id: str, form_token: str, *, auth_data: AuthData): - app_model, caller, caller_kind = auth_data.require_app_context() + app_model, _caller, _caller_kind = auth_data.require_app_context() service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: - raise NotFound("Form not found") + raise HumanInputFormNotFound() _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_openapi(form) service.ensure_form_active(form) return _jsonify_form_definition(form) - @auth_router.guard(scope=Scope.APPS_RUN) + @auth_router.guard( + scope=Scope.APPS_RUN, + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), + ) @returns(200, FormSubmitResponse, description="Form submitted") @accepts(body=HumanInputFormSubmitPayload) def post(self, app_id: str, form_token: str, *, auth_data: AuthData, body: HumanInputFormSubmitPayload): @@ -80,7 +91,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource): service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: - raise NotFound("Form not found") + raise HumanInputFormNotFound() _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_openapi(form) @@ -106,6 +117,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource): submission_end_user_id=submission_end_user_id, ) except FormNotFoundError: - raise NotFound("Form not found") + raise HumanInputFormNotFound() return FormSubmitResponse() diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index 61ebb3012dc..916c93707dd 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -19,9 +19,10 @@ from werkzeug.exceptions import NotFound, UnprocessableEntity from controllers.common.fields import EventStreamResponse from controllers.common.schema import query_params_from_model +from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, RBACRequirement from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter @@ -46,7 +47,10 @@ class WorkflowEventsQuery(BaseModel): class OpenApiWorkflowEventsApi(Resource): @openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) @openapi_ns.response(200, "SSE event stream", openapi_ns.models[EventStreamResponse.__name__]) - @auth_router.guard(scope=Scope.APPS_RUN) + @auth_router.guard( + scope=Scope.APPS_RUN, + rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), + ) def get(self, app_id: str, task_id: str, *, auth_data: AuthData): app_model, caller, caller_kind = auth_data.require_app_context() app_mode = AppMode.value_of(app_model.mode) diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index d670c7f5a6f..932ec71c769 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -2,6 +2,7 @@ from typing import Any, cast from flask_restx import Resource from pydantic import Field +from sqlalchemy import select from controllers.common.fields import Parameters from controllers.common.schema import register_response_schema_models @@ -9,7 +10,11 @@ from controllers.service_api import service_api_ns from controllers.service_api.app.error import AppUnavailableError from controllers.service_api.wraps import validate_app_token from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form +from extensions.ext_database import db from fields.base import ResponseModel +from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus +from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode from services.app_service import AppService @@ -29,6 +34,40 @@ class AppMetaResponse(ResponseModel): register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse) +def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]: + app_model_config = app_model.app_model_config + features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {} + + agent = db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == app_model.tenant_id, + Agent.app_id == app_model.id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None or not agent.active_config_snapshot_id: + raise AppUnavailableError() + + snapshot = db.session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == app_model.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise AppUnavailableError() + + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables) + + @service_api_ns.route("/parameters") class AppParameterApi(Resource): """Resource for app variables.""" @@ -61,12 +100,16 @@ class AppParameterApi(Resource): Returns the input form parameters and configuration for the application. """ - if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + features_dict: dict[str, Any] + user_input_form: list[dict[str, Any]] + if app_model.mode == AppMode.AGENT: + features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form(app_model) + elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app_model.workflow if workflow is None: raise AppUnavailableError() - features_dict: dict[str, Any] = workflow.features_dict + features_dict = workflow.features_dict user_input_form = workflow.user_input_form(to_old_structure=True) else: app_model_config = app_model.app_model_config diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 256521ab654..67397965384 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from typing import Any, cast from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig from core.app.apps.base_app_queue_manager import AppQueueManager @@ -22,7 +22,7 @@ from core.app.entities.queue_entities import ( from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature from core.app.layers.conversation_variable_persist_layer import ConversationVariablePersistenceLayer from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer -from core.db.session_factory import session_factory +from core.db.session_factory import create_session, session_factory from core.moderation.base import ModerationError from core.moderation.input_moderation import InputModeration from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -107,7 +107,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): workflow_execution_id=self.application_generate_entity.workflow_run_id, ) - with Session(db.engine, expire_on_commit=False) as session: + with create_session() as session: app_record = session.scalar(select(App).where(App.id == app_config.app_id)) if not app_record: @@ -204,6 +204,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), ) + # Release the Flask scoped session before workflow execution so a checked-out DB connection + # is not held for the lifetime of the graph run. db.session.close() # RUN WORKFLOW @@ -368,7 +370,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): :return: List of conversation variables ready for use """ - with sessionmaker(bind=db.engine).begin() as session: + with create_session() as session, session.begin(): existing_variables = self._load_existing_conversation_variables(session) if not existing_variables: diff --git a/api/core/app/apps/agent_app/app_config_manager.py b/api/core/app/apps/agent_app/app_config_manager.py index 6721f8bcb1c..0dc04735cc0 100644 --- a/api/core/app/apps/agent_app/app_config_manager.py +++ b/api/core/app/apps/agent_app/app_config_manager.py @@ -21,6 +21,7 @@ from core.app.app_config.entities import ( EasyUIBasedAppModelConfigFrom, PromptTemplateEntity, ) +from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation @@ -98,8 +99,7 @@ class AgentAppConfigManager(BaseAppConfigManager): # pipeline's bookkeeping (token counting, persistence). base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value base["pre_prompt"] = agent_soul.prompt.system_prompt or "" - # Agent App takes the user message directly; no completion-style inputs form. - base.setdefault("user_input_form", []) + base["user_input_form"] = agent_app_variables_to_user_input_form(agent_soul.app_variables) return base diff --git a/api/core/app/apps/agent_app/app_variable_projection.py b/api/core/app/apps/agent_app/app_variable_projection.py new file mode 100644 index 00000000000..0feea12f5b8 --- /dev/null +++ b/api/core/app/apps/agent_app/app_variable_projection.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from models.agent_config_entities import AppVariableConfig + + +def agent_app_variables_to_user_input_form(app_variables: Sequence[AppVariableConfig]) -> list[dict[str, Any]]: + """Project Agent Soul app variables into the legacy service-API parameter form.""" + + user_input_form: list[dict[str, Any]] = [] + for variable in app_variables: + form_type = _form_type_for_agent_variable(variable.type) + form_item: dict[str, Any] = { + "label": variable.name, + "variable": variable.name, + "required": variable.required, + } + if variable.default is not None: + form_item["default"] = variable.default + user_input_form.append({form_type: form_item}) + return user_input_form + + +def _form_type_for_agent_variable(variable_type: str) -> str: + normalized = variable_type.strip().lower() + if normalized in {"number", "integer", "float"}: + return "number" + if normalized in {"boolean", "bool"}: + return "checkbox" + if normalized in {"paragraph", "long_text", "multiline"}: + return "paragraph" + return "text-input" + + +__all__ = ["agent_app_variables_to_user_input_form"] diff --git a/api/core/app/apps/agent_chat/app_runner.py b/api/core/app/apps/agent_chat/app_runner.py index cae0eee0df0..5f9c75129b5 100644 --- a/api/core/app/apps/agent_chat/app_runner.py +++ b/api/core/app/apps/agent_chat/app_runner.py @@ -12,10 +12,10 @@ from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.base_app_runner import AppRunner from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity from core.app.entities.queue_entities import QueueAnnotationReplyEvent +from core.db.session_factory import create_session from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance from core.moderation.base import ModerationError -from extensions.ext_database import db from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel @@ -47,7 +47,10 @@ class AgentChatAppRunner(AppRunner): app_config = application_generate_entity.app_config app_config = cast(AgentChatAppConfig, app_config) app_stmt = select(App).where(App.id == app_config.app_id) - app_record = db.session.scalar(app_stmt) + with create_session() as session: + app_record = session.scalar(app_stmt) + if app_record: + session.expunge(app_record) if not app_record: raise ValueError("App not found") @@ -185,14 +188,18 @@ class AgentChatAppRunner(AppRunner): if {ModelFeature.MULTI_TOOL_CALL, ModelFeature.TOOL_CALL}.intersection(model_schema.features or []): agent_entity.strategy = AgentEntity.Strategy.FUNCTION_CALLING conversation_stmt = select(Conversation).where(Conversation.id == conversation.id) - conversation_result = db.session.scalar(conversation_stmt) - if conversation_result is None: - raise ValueError("Conversation not found") msg_stmt = select(Message).where(Message.id == message.id) - message_result = db.session.scalar(msg_stmt) + with create_session() as session: + conversation_result = session.scalar(conversation_stmt) + if conversation_result is None: + raise ValueError("Conversation not found") + + message_result = session.scalar(msg_stmt) + if message_result is not None: + session.expunge(message_result) + session.expunge(conversation_result) if message_result is None: raise ValueError("Message not found") - db.session.close() runner_cls: type[FunctionCallAgentRunner] | type[CotChatAgentRunner] | type[CotCompletionAgentRunner] # start agent runner diff --git a/api/core/app/apps/chat/app_runner.py b/api/core/app/apps/chat/app_runner.py index 077c5239f39..9c2eaf60dc7 100644 --- a/api/core/app/apps/chat/app_runner.py +++ b/api/core/app/apps/chat/app_runner.py @@ -11,6 +11,7 @@ from core.app.entities.app_invoke_entities import ( ) from core.app.entities.queue_entities import QueueAnnotationReplyEvent from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler +from core.db.session_factory import create_session from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance from core.moderation.base import ModerationError @@ -46,7 +47,10 @@ class ChatAppRunner(AppRunner): app_config = application_generate_entity.app_config app_config = cast(ChatAppConfig, app_config) stmt = select(App).where(App.id == app_config.app_id) - app_record = db.session.scalar(stmt) + with create_session() as session: + app_record = session.scalar(stmt) + if app_record: + session.expunge(app_record) if not app_record: raise ValueError("App not found") @@ -216,6 +220,8 @@ class ChatAppRunner(AppRunner): model=application_generate_entity.model_conf.model, ) + # Release the Flask scoped session before LLM streaming so a checked-out DB connection + # is not held for the lifetime of the provider response. db.session.close() invoke_result = model_instance.invoke_llm( diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index c9486b5821f..67f37e78ab9 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -51,8 +51,11 @@ from core.tools.entities.tool_entities import ToolProviderType from core.tools.tool_manager import ToolManager from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager -from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_forms import ( + load_form_dispositions_by_form_id, +) from core.workflow.human_input_policy import ( + FormDisposition, HumanInputSurface, enrich_human_input_pause_reasons, resolve_human_input_pause_reason_inputs, @@ -340,13 +343,14 @@ class WorkflowResponseConverter: human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)] expiration_times_by_form_id: dict[str, datetime] = {} display_in_ui_by_form_id: dict[str, bool] = {} - form_token_by_form_id: dict[str, str] = {} + dispositions_by_form_id: dict[str, FormDisposition] = {} if human_input_form_ids: stmt = select( HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition, ).where(HumanInputForm.id.in_(human_input_form_ids)) + hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from) with Session(bind=db.engine) as session: for form_id, expiration_time, form_definition in session.execute(stmt): expiration_times_by_form_id[str(form_id)] = expiration_time @@ -355,17 +359,17 @@ class WorkflowResponseConverter: except (TypeError, json.JSONDecodeError): definition_payload = {} display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) - form_token_by_form_id = load_form_tokens_by_form_id( + dispositions_by_form_id = load_form_dispositions_by_form_id( human_input_form_ids, session=session, - surface=_INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from), + surface=hitl_surface, ) # Reconnect paths must preserve the same pause-reason contract as live streams; # otherwise clients see schema drift after resume. pause_reasons = enrich_human_input_pause_reasons( pause_reasons, - form_tokens_by_form_id=form_token_by_form_id, + dispositions_by_form_id=dispositions_by_form_id, expiration_times_by_form_id={ form_id: int(expiration_time.timestamp()) for form_id, expiration_time in expiration_times_by_form_id.items() @@ -379,6 +383,7 @@ class WorkflowResponseConverter: expiration_time = expiration_times_by_form_id.get(reason.form_id) if expiration_time is None: raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}") + disposition = dispositions_by_form_id.get(reason.form_id) responses.append( HumanInputRequiredResponse( task_id=task_id, @@ -391,7 +396,8 @@ class WorkflowResponseConverter: inputs=reason.inputs, actions=reason.actions, display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False), - form_token=form_token_by_form_id.get(reason.form_id), + form_token=disposition.form_token if disposition else None, + approval_channels=list(disposition.approval_channels) if disposition else [], resolved_default_values=reason.resolved_default_values, expiration_time=int(expiration_time.timestamp()), ), diff --git a/api/core/app/apps/completion/app_runner.py b/api/core/app/apps/completion/app_runner.py index 6bb1ecdcb19..38ef672ae22 100644 --- a/api/core/app/apps/completion/app_runner.py +++ b/api/core/app/apps/completion/app_runner.py @@ -10,6 +10,7 @@ from core.app.entities.app_invoke_entities import ( CompletionAppGenerateEntity, ) from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler +from core.db.session_factory import create_session from core.model_manager import ModelInstance from core.moderation.base import ModerationError from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -39,7 +40,10 @@ class CompletionAppRunner(AppRunner): app_config = application_generate_entity.app_config app_config = cast(CompletionAppConfig, app_config) stmt = select(App).where(App.id == app_config.app_id) - app_record = db.session.scalar(stmt) + with create_session() as session: + app_record = session.scalar(stmt) + if app_record: + session.expunge(app_record) if not app_record: raise ValueError("App not found") @@ -174,6 +178,8 @@ class CompletionAppRunner(AppRunner): model=application_generate_entity.model_conf.model, ) + # Release the Flask scoped session before LLM streaming so a checked-out DB connection + # is not held for the lifetime of the provider response. db.session.close() invoke_result = model_instance.invoke_llm( diff --git a/api/core/app/apps/message_based_app_queue_manager.py b/api/core/app/apps/message_based_app_queue_manager.py index 0b97809bf3a..3c7102971f1 100644 --- a/api/core/app/apps/message_based_app_queue_manager.py +++ b/api/core/app/apps/message_based_app_queue_manager.py @@ -11,6 +11,7 @@ from core.app.entities.queue_entities import ( QueueMessageEndEvent, QueueStopEvent, ) +from models.model import AppMode class MessageBasedAppQueueManager(AppQueueManager): @@ -47,4 +48,6 @@ class MessageBasedAppQueueManager(AppQueueManager): self.stop_listen() if pub_from == PublishFrom.APPLICATION_MANAGER and self._is_stopped(): + if self._app_mode == AppMode.ADVANCED_CHAT.value: + return raise GenerateTaskStoppedError() diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 2ee0ae27ebc..3ad0990cbb4 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -3,6 +3,7 @@ import time from typing import cast from sqlalchemy import select +from sqlalchemy.orm import Session from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.pipeline.pipeline_config_manager import PipelineConfig @@ -14,12 +15,12 @@ from core.app.entities.app_invoke_entities import ( build_dify_run_context, ) from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer +from core.db.session_factory import create_session from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry -from extensions.ext_database import db from graphon.enums import WorkflowType from graphon.graph import Graph from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent @@ -83,22 +84,24 @@ class PipelineRunner(WorkflowBasedAppRunner): user_from = self._resolve_user_from(invoke_from) user_id = None - if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}: - end_user = db.session.get(EndUser, self.application_generate_entity.user_id) - if end_user: - user_id = end_user.session_id - else: - user_id = self.application_generate_entity.user_id + with create_session() as session: + if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}: + end_user = session.get(EndUser, self.application_generate_entity.user_id) + if end_user: + user_id = end_user.session_id + else: + user_id = self.application_generate_entity.user_id - pipeline = db.session.get(Pipeline, app_config.app_id) - if not pipeline: - raise ValueError("Pipeline not found") + pipeline = session.get(Pipeline, app_config.app_id) + if not pipeline: + raise ValueError("Pipeline not found") - workflow = self.get_workflow(pipeline=pipeline, workflow_id=app_config.workflow_id) - if not workflow: - raise ValueError("Workflow not initialized") + workflow = self.get_workflow(session=session, pipeline=pipeline, workflow_id=app_config.workflow_id) + if not workflow: + raise ValueError("Workflow not initialized") - db.session.close() + session.expunge(pipeline) + session.expunge(workflow) # if only single iteration run is requested if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: @@ -208,12 +211,12 @@ class PipelineRunner(WorkflowBasedAppRunner): ) self._handle_event(workflow_entry, event) - def get_workflow(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None: + def get_workflow(self, session: Session, pipeline: Pipeline, workflow_id: str) -> Workflow | None: """ Get workflow """ # fetch workflow by workflow_id - workflow = db.session.scalar( + workflow = session.scalar( select(Workflow) .where(Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.id == workflow_id) .limit(1) @@ -298,11 +301,11 @@ class PipelineRunner(WorkflowBasedAppRunner): """ if isinstance(event, GraphRunFailedEvent): if document_id and dataset_id: - document = db.session.scalar( - select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) - ) - if document: - document.indexing_status = "error" - document.error = event.error or "Unknown error" - db.session.add(document) - db.session.commit() + with create_session() as session, session.begin(): + document = session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) + ) + if document: + document.indexing_status = "error" + document.error = event.error or "Unknown error" + session.add(document) diff --git a/api/core/app/apps/workflow/app_queue_manager.py b/api/core/app/apps/workflow/app_queue_manager.py index fcdd1465d4f..7824d33b875 100644 --- a/api/core/app/apps/workflow/app_queue_manager.py +++ b/api/core/app/apps/workflow/app_queue_manager.py @@ -1,7 +1,6 @@ from typing import override from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import ( AppQueueEvent, @@ -43,6 +42,3 @@ class WorkflowAppQueueManager(AppQueueManager): | QueueWorkflowPartialSuccessEvent, ): self.stop_listen() - - if pub_from == PublishFrom.APPLICATION_MANAGER and self._is_stopped(): - raise GenerateTaskStoppedError() diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 803fdacf78d..3a8107e0461 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -288,6 +288,7 @@ class HumanInputRequiredResponse(StreamResponse): actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False form_token: str | None = None + approval_channels: list[str] = Field(default_factory=list) resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) expiration_time: int = Field(..., description="Unix timestamp in seconds") @@ -311,6 +312,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False form_token: str | None = None + approval_channels: list[str] = Field(default_factory=list) resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) expiration_time: int @@ -325,6 +327,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): actions=data.actions, display_in_ui=data.display_in_ui, form_token=data.form_token, + approval_channels=data.approval_channels, resolved_default_values=data.resolved_default_values, expiration_time=data.expiration_time, ) diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index c76cb865c31..d022b002f72 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -3,7 +3,6 @@ from collections.abc import Generator, Mapping from typing import Any, cast from sqlalchemy import select -from sqlalchemy.orm import Session from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator @@ -13,10 +12,19 @@ from core.app.apps.completion.app_generator import CompletionAppGenerator from core.app.apps.workflow.app_generator import WorkflowAppGenerator from core.app.entities.app_invoke_entities import InvokeFrom from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig +from core.db.session_factory import create_session from core.plugin.backwards_invocation.base import BaseBackwardsInvocation from extensions.ext_database import db -from models import Account -from models.model import App, AppMode, EndUser +from models import Account, TenantAccountJoin +from models.model import ( + App, + AppMode, + AppModelConfig, + AppModelConfigDict, + EndUser, + load_annotation_reply_config, +) +from models.workflow import Workflow from services.end_user_service import EndUserService @@ -30,18 +38,18 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """Retrieve app parameters.""" if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow = app.workflow + workflow = cls._get_workflow(app) if workflow is None: raise ValueError("unexpected app type") features_dict: dict[str, Any] = workflow.features_dict user_input_form = workflow.user_input_form(to_old_structure=True) else: - app_model_config = app.app_model_config - if app_model_config is None: + app_model_config_dict = cls._get_app_model_config_dict(app) + if app_model_config_dict is None: raise ValueError("unexpected app type") - features_dict = cast(dict[str, Any], app_model_config.to_dict()) + features_dict = cast(dict[str, Any], app_model_config_dict) user_input_form = features_dict.get("user_input_form", []) @@ -68,7 +76,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): if not user_id: user = EndUserService.get_or_create_end_user(app) else: - user = cls._get_user(user_id) + user = cls._get_user(user_id, app) conversation_id = conversation_id or "" @@ -79,7 +87,10 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files) case AppMode.WORKFLOW: - return cls.invoke_workflow_app(app, user, stream, inputs, files) + workflow = cls._get_workflow(app) + if not workflow: + raise ValueError("unexpected app type") + return cls.invoke_workflow_app(app, workflow, user, stream, inputs, files) case AppMode.COMPLETION: return cls.invoke_completion_app(app, user, stream, inputs, files) case _: @@ -101,7 +112,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """ match app.mode: case AppMode.ADVANCED_CHAT: - workflow = app.workflow + workflow = cls._get_workflow(app) if not workflow: raise ValueError("unexpected app type") @@ -158,6 +169,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): def invoke_workflow_app( cls, app: App, + workflow: Workflow, user: EndUser | Account, stream: bool, inputs: Mapping, @@ -166,10 +178,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """ invoke workflow app """ - workflow = app.workflow - if not workflow: - raise ValueError("unexpected app type") - pause_config = PauseStateLayerConfig( session_factory=db.engine, state_owner_user_id=workflow.created_by, @@ -207,16 +215,26 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): ) @classmethod - def _get_user(cls, user_id: str) -> EndUser | Account: + def _get_user(cls, user_id: str, app: App) -> EndUser | Account: """ get the user by user id """ - with Session(db.engine, expire_on_commit=False) as session: - stmt = select(EndUser).where(EndUser.id == user_id) + with create_session() as session: + stmt = select(EndUser).where( + EndUser.id == user_id, + EndUser.tenant_id == app.tenant_id, + EndUser.app_id == app.id, + ) user = session.scalar(stmt) if not user: - stmt = select(Account).where(Account.id == user_id) + stmt = select(Account).where( + Account.id == user_id, + Account.id == TenantAccountJoin.account_id, + TenantAccountJoin.tenant_id == app.tenant_id, + ) user = session.scalar(stmt) + if user: + session.expunge(user) if not user: raise ValueError("user not found") @@ -229,7 +247,10 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): get app """ try: - app = db.session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1)) + with create_session() as session: + app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1)) + if app: + session.expunge(app) except Exception: raise ValueError("app not found") @@ -237,3 +258,41 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): raise ValueError("app not found") return app + + @classmethod + def _get_workflow(cls, app: App) -> Workflow | None: + """ + get workflow without relying on App.workflow's request-scoped session property + """ + if not app.workflow_id: + return None + + with create_session() as session: + workflow = session.scalar( + select(Workflow) + .where(Workflow.id == app.workflow_id, Workflow.tenant_id == app.tenant_id, Workflow.app_id == app.id) + .limit(1) + ) + if workflow: + session.expunge(workflow) + return workflow + + @classmethod + def _get_app_model_config_dict(cls, app: App) -> AppModelConfigDict | None: + """ + get app model config features without relying on request-scoped session-backed model properties + """ + if not app.app_model_config_id: + return None + + with create_session() as session: + app_model_config = session.scalar( + select(AppModelConfig) + .where(AppModelConfig.id == app.app_model_config_id, AppModelConfig.app_id == app.id) + .limit(1) + ) + if app_model_config is None: + return None + + annotation_reply = load_annotation_reply_config(session, app_model_config.app_id) + return app_model_config.to_dict(annotation_reply=annotation_reply) diff --git a/api/core/rbac/entities.py b/api/core/rbac/entities.py index 7f08a530f57..d65f11edf7b 100644 --- a/api/core/rbac/entities.py +++ b/api/core/rbac/entities.py @@ -22,23 +22,35 @@ class RBACPermission(StrEnum): APP_VIEW_LAYOUT = "app_view_layout" APP_TEST_AND_RUN = "app_test_and_run" + APP_PREVIEW = "app_preview" APP_CREATE_AND_MANAGEMENT = "app_create_and_management" APP_RELEASE_AND_VERSION = "app_release_and_version" APP_IMPORT_EXPORT_DSL = "app_import_export_dsl" APP_EDIT = "app_edit" APP_MONITOR = "app_monitor" APP_DELETE = "app_delete" + APP_ACCESS_CONFIG = "app_access_config" + DATASET_PREVIEW = "dataset_preview" DATASET_READONLY = "dataset_readonly" DATASET_EDIT = "dataset_edit" DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management" DATASET_PIPELINE_TEST = "dataset_pipeline_test" DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download" + DATASET_RETRIEVAL_RECALL = "dataset_retrieval_recall" + DATASET_USE = "dataset_use" + DATASET_DELETE_FILE = "dataset_delete_file" + DATASET_PIPELINE_RELEASE = "dataset_pipeline_release" + DATASET_DELETE = "dataset_delete" + DATASET_ACCESS_CONFIG = "dataset_access_config" DATASET_API_KEY_MANAGE = "dataset_api_key_manage" DATASET_EXTERNAL_CONNECT = "dataset_external_connect" DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl" + WORKSPACE_MEMBER_MANAGE = "workspace_member_manage" WORKSPACE_ROLE_MANAGE = "workspace_role_manage" + API_EXTENSION_MANAGE = "api_extension_manage" + CUSTOMIZATION_MANAGE = "customization_manage" SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify" SNIPPETS_MANAGE = "snippets_management" @@ -49,6 +61,7 @@ class RBACPermission(StrEnum): PLUGIN_DEBUG = "plugin_debug" CREDENTIAL_USE = "credential_use" + CREDENTIAL_CREATE = "credential_create" CREDENTIAL_MANAGE = "credential_manage" TOOL_MANAGE = "tool_manage" diff --git a/api/core/workflow/human_input_forms.py b/api/core/workflow/human_input_forms.py index fe3c161a326..b850cd23914 100644 --- a/api/core/workflow/human_input_forms.py +++ b/api/core/workflow/human_input_forms.py @@ -12,60 +12,61 @@ from collections.abc import Sequence from sqlalchemy import select from sqlalchemy.orm import Session -from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token +from core.workflow.human_input_policy import ( + FormDisposition, + HumanInputSurface, + disposition_for_surface, +) from extensions.ext_database import db from models.human_input import HumanInputFormRecipient, RecipientType +def load_form_dispositions_by_form_id( + form_ids: Sequence[str], + *, + session: Session | None = None, + surface: HumanInputSurface | None = None, +) -> dict[str, FormDisposition]: + """Resolve each paused form's resume token and approval channels for `surface`.""" + unique_form_ids = list(dict.fromkeys(form_ids)) + if not unique_form_ids: + return {} + + if session is not None: + return _load_form_dispositions_by_form_id(session, unique_form_ids, surface=surface) + + with Session(bind=db.engine, expire_on_commit=False) as new_session: + return _load_form_dispositions_by_form_id(new_session, unique_form_ids, surface=surface) + + +def _load_form_dispositions_by_form_id( + session: Session, + form_ids: Sequence[str], + *, + surface: HumanInputSurface | None, +) -> dict[str, FormDisposition]: + recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {} + stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) + for recipient in session.scalars(stmt): + recipients_by_form_id.setdefault(recipient.form_id, []).append( + (recipient.recipient_type, recipient.access_token or "") + ) + return { + form_id: disposition_for_surface(recipients, surface=surface) + for form_id, recipients in recipients_by_form_id.items() + } + + def load_form_tokens_by_form_id( form_ids: Sequence[str], *, session: Session | None = None, surface: HumanInputSurface | None = None, ) -> dict[str, str]: - """Load the preferred access token for each human input form.""" - unique_form_ids = list(dict.fromkeys(form_ids)) - if not unique_form_ids: - return {} - - if session is not None: - return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface) - - with Session(bind=db.engine, expire_on_commit=False) as new_session: - return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface) - - -def _load_form_tokens_by_form_id( - session: Session, - form_ids: Sequence[str], - *, - surface: HumanInputSurface | None = None, -) -> dict[str, str]: - recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {} - stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) - for recipient in session.scalars(stmt): - if not recipient.access_token: - continue - recipients_by_form_id.setdefault(recipient.form_id, []).append( - (recipient.recipient_type, recipient.access_token) - ) - - tokens_by_form_id: dict[str, str] = {} - for form_id, recipients in recipients_by_form_id.items(): - token = _get_surface_form_token(recipients, surface=surface) - if token is not None: - tokens_by_form_id[form_id] = token - return tokens_by_form_id - - -def _get_surface_form_token( - recipients: Sequence[tuple[RecipientType, str]], - *, - surface: HumanInputSurface | None, -) -> str | None: - if surface in {HumanInputSurface.SERVICE_API, HumanInputSurface.OPENAPI}: - for recipient_type, token in recipients: - if recipient_type == RecipientType.STANDALONE_WEB_APP and token: - return token - - return get_preferred_form_token(recipients) + """Resume tokens only, for callers that don't surface approval channels.""" + dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface) + return { + form_id: disposition.form_token + for form_id, disposition in dispositions.items() + if disposition.form_token is not None + } diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index e95d753ae96..d6f7df52354 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -2,14 +2,14 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any +from typing import Any, NamedTuple from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig from graphon.nodes.human_input.enums import ValueSourceType from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool from graphon.variables import ArrayStringSegment -from models.human_input import RecipientType +from models.human_input import ApprovalChannel, RecipientType class HumanInputSurface(StrEnum): @@ -20,7 +20,7 @@ class HumanInputSurface(StrEnum): # SERVICE_API and OPENAPI are intentionally narrower than CONSOLE: token callers # should only be able to act on end-user web forms, not internal console flows. -_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = { +ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = { HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}), HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}), HumanInputSurface.OPENAPI: frozenset({RecipientType.STANDALONE_WEB_APP}), @@ -41,7 +41,7 @@ def is_recipient_type_allowed_for_surface( ) -> bool: if recipient_type is None: return False - return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface] + return recipient_type in ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface] def get_preferred_form_token( @@ -59,10 +59,39 @@ def get_preferred_form_token( return chosen_token +class FormDisposition(NamedTuple): + """How a paused form resolves for one API surface. + + A form's recipients split into those the surface may act on (yielding a resume + `form_token`) and those it may not (their channels named in `approval_channels` + so the caller is told where approval actually happens instead). + """ + + form_token: str | None + approval_channels: list[ApprovalChannel] + + +def disposition_for_surface( + recipients: Sequence[tuple[RecipientType, str]], + *, + surface: HumanInputSurface | None, +) -> FormDisposition: + if surface is None: + return FormDisposition(form_token=get_preferred_form_token(recipients), approval_channels=[]) + allowed = ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface] + actionable = [(recipient_type, token) for recipient_type, token in recipients if recipient_type in allowed] + return FormDisposition( + form_token=get_preferred_form_token(actionable), + approval_channels=sorted( + {recipient_type.approval_channel for recipient_type, _ in recipients if recipient_type not in allowed} + ), + ) + + def enrich_human_input_pause_reasons( reasons: Sequence[Mapping[str, Any]], *, - form_tokens_by_form_id: Mapping[str, str], + dispositions_by_form_id: Mapping[str, FormDisposition], expiration_times_by_form_id: Mapping[str, int], ) -> list[dict[str, Any]]: enriched: list[dict[str, Any]] = [] @@ -71,7 +100,9 @@ def enrich_human_input_pause_reasons( if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED: form_id = updated.get("form_id") if isinstance(form_id, str): - updated["form_token"] = form_tokens_by_form_id.get(form_id) + disposition = dispositions_by_form_id.get(form_id) + updated["form_token"] = disposition.form_token if disposition else None + updated["approval_channels"] = list(disposition.approval_channels) if disposition else [] expiration_time = expiration_times_by_form_id.get(form_id) if expiration_time is not None: updated["expiration_time"] = expiration_time diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index af0d77411ba..f1c2d574e8c 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -25,7 +25,7 @@ from extensions.redis_names import ( serialize_redis_name_args, ) from libs.broadcast_channel.channel import BroadcastChannel as BroadcastChannelProtocol -from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel +from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel from libs.broadcast_channel.redis.streams_channel import StreamsBroadcastChannel @@ -457,16 +457,14 @@ def init_app(app: DifyApp): def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol: assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here." - join_timeout_ms = dify_config.PUBSUB_LISTENER_JOIN_TIMEOUT_MS if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded": - return ShardedRedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms) + return ShardedRedisBroadcastChannel(_pubsub_redis_client) if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "streams": return StreamsBroadcastChannel( _pubsub_redis_client, retention_seconds=dify_config.PUBSUB_STREAMS_RETENTION_SECONDS, - join_timeout_ms=join_timeout_ms, ) - return RedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms) + return RedisBroadcastChannel(_pubsub_redis_client) def redis_fallback[T](default_return: T | None = None): # type: ignore diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index ec64395d6fd..07bcbad26e3 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -291,6 +291,11 @@ class AgentConfigSnapshotListResponse(ResponseModel): data: list[AgentConfigSnapshotSummaryResponse] +class AgentConfigSnapshotRestoreResponse(ResponseModel): + result: Literal["success"] + active_config_snapshot_id: str + + class AgentComposerAgentResponse(ResponseModel): id: str name: str diff --git a/api/libs/broadcast_channel/redis/__init__.py b/api/libs/broadcast_channel/redis/__init__.py index f92c94f7360..8ce71e2823e 100644 --- a/api/libs/broadcast_channel/redis/__init__.py +++ b/api/libs/broadcast_channel/redis/__init__.py @@ -1,4 +1,4 @@ -from .channel import BroadcastChannel +from .pubsub_channel import BroadcastChannel from .sharded_channel import ShardedRedisBroadcastChannel __all__ = ["BroadcastChannel", "ShardedRedisBroadcastChannel"] diff --git a/api/libs/broadcast_channel/redis/_subscription.py b/api/libs/broadcast_channel/redis/_subscription.py index 5af42d12538..912a48d26ae 100644 --- a/api/libs/broadcast_channel/redis/_subscription.py +++ b/api/libs/broadcast_channel/redis/_subscription.py @@ -7,6 +7,7 @@ from typing import Any, Self, override from libs.broadcast_channel.channel import Subscription from libs.broadcast_channel.exc import SubscriptionClosedError +from libs.broadcast_channel.signals import SIG_CLOSE from redis import Redis, RedisCluster from redis.client import PubSub @@ -26,8 +27,6 @@ class RedisSubscriptionBase(Subscription): client: Redis | RedisCluster, pubsub: PubSub, topic: str, - *, - join_timeout_ms: int = 2000, ): # The _pubsub is None only if the subscription is closed. self._client = client @@ -39,11 +38,6 @@ class RedisSubscriptionBase(Subscription): self._listener_thread: threading.Thread | None = None self._start_lock = threading.Lock() self._started = False - # Max time close() will wait for the listener thread to finish before - # returning. Bounds SSE close tail latency. The listener is a daemon - # and exits on its own within one poll window (~1s), so a low value - # here just means close() returns sooner without breaking anything. - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def _start_if_needed(self) -> None: """Start the subscription if not already started.""" @@ -90,6 +84,11 @@ class RedisSubscriptionBase(Subscription): if raw_message is None: continue + # If close() sent a control event to unblock us, exit immediately + # without processing any message — the subscription is shutting down. + if self._closed.is_set(): + break + if raw_message.get("type") != self._get_message_type(): continue @@ -119,6 +118,8 @@ class RedisSubscriptionBase(Subscription): continue self._enqueue_message(payload_bytes) + if payload_bytes == SIG_CLOSE: + break _logger.debug("%s listener thread stopped for channel %s", self._get_subscription_type().title(), self._topic) try: @@ -212,13 +213,16 @@ class RedisSubscriptionBase(Subscription): return self._closed.set() + # Send a control event on the same Redis channel to unblock the + self._publish_close_event() + # NOTE: PubSub is not thread-safe. More specifically, the `PubSub.close` method and the # message retrieval method should NOT be called concurrently. # # Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread. listener = self._listener_thread if listener is not None: - listener.join(timeout=self._join_timeout_ms / 1000.0) + listener.join(timeout=2) self._listener_thread = None # Abstract methods to be implemented by subclasses @@ -226,6 +230,15 @@ class RedisSubscriptionBase(Subscription): """Return the subscription type (e.g., 'regular' or 'sharded').""" raise NotImplementedError + def _publish_close_event(self) -> None: + """Publish a control event on the Redis channel to unblock the listener. + + This is called by close() after setting _closed. The subclass should + publish an empty message on the same topic so that a blocking + get_message() call in the listener thread returns promptly. + """ + raise NotImplementedError + def _subscribe(self) -> None: """Subscribe to the Redis topic using the appropriate command.""" raise NotImplementedError diff --git a/api/libs/broadcast_channel/redis/channel.py b/api/libs/broadcast_channel/redis/pubsub_channel.py similarity index 82% rename from api/libs/broadcast_channel/redis/channel.py rename to api/libs/broadcast_channel/redis/pubsub_channel.py index bf304cc4a0b..a784bb98f13 100644 --- a/api/libs/broadcast_channel/redis/channel.py +++ b/api/libs/broadcast_channel/redis/pubsub_channel.py @@ -1,13 +1,17 @@ from __future__ import annotations +import logging from typing import Any, override from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription +from libs.broadcast_channel.signals import SIG_CLOSE from redis import Redis, RedisCluster from ._subscription import RedisSubscriptionBase +logger = logging.getLogger(__name__) + class BroadcastChannel: """ @@ -22,16 +26,11 @@ class BroadcastChannel: def __init__( self, redis_client: Redis | RedisCluster, - *, - join_timeout_ms: int = 2000, ): self._client = redis_client - # See `RedisSubscriptionBase._join_timeout_ms`: how long close() - # waits for the listener thread before returning. - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> Topic: - return Topic(self._client, topic, join_timeout_ms=self._join_timeout_ms) + return Topic(self._client, topic) class Topic: @@ -39,13 +38,10 @@ class Topic: self, redis_client: Redis | RedisCluster, topic: str, - *, - join_timeout_ms: int = 2000, ): self._client = redis_client self._topic = topic self._redis_topic = serialize_redis_name(topic) - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def as_producer(self) -> Producer: return self @@ -61,7 +57,6 @@ class Topic: client=self._client, pubsub=self._client.pubsub(), topic=self._redis_topic, - join_timeout_ms=self._join_timeout_ms, ) @@ -72,6 +67,13 @@ class _RedisSubscription(RedisSubscriptionBase): def _get_subscription_type(self) -> str: return "regular" + @override + def _publish_close_event(self) -> None: + try: + self._client.publish(self._topic, SIG_CLOSE) + except Exception: + logger.exception("failed to publish close event") + @override def _subscribe(self) -> None: assert self._pubsub is not None diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py index 68e9f8b23ef..aabae6a5c11 100644 --- a/api/libs/broadcast_channel/redis/sharded_channel.py +++ b/api/libs/broadcast_channel/redis/sharded_channel.py @@ -1,13 +1,17 @@ from __future__ import annotations +import logging from typing import Any, override from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription +from libs.broadcast_channel.signals import SIG_CLOSE from redis import Redis, RedisCluster from ._subscription import RedisSubscriptionBase +logger = logging.getLogger(__name__) + class ShardedRedisBroadcastChannel: """ @@ -20,14 +24,11 @@ class ShardedRedisBroadcastChannel: def __init__( self, redis_client: Redis | RedisCluster, - *, - join_timeout_ms: int = 2000, ): self._client = redis_client - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> ShardedTopic: - return ShardedTopic(self._client, topic, join_timeout_ms=self._join_timeout_ms) + return ShardedTopic(self._client, topic) class ShardedTopic: @@ -35,13 +36,10 @@ class ShardedTopic: self, redis_client: Redis | RedisCluster, topic: str, - *, - join_timeout_ms: int = 2000, ): self._client = redis_client self._topic = topic self._redis_topic = serialize_redis_name(topic) - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def as_producer(self) -> Producer: return self @@ -57,7 +55,6 @@ class ShardedTopic: client=self._client, pubsub=self._client.pubsub(), topic=self._redis_topic, - join_timeout_ms=self._join_timeout_ms, ) @@ -68,6 +65,13 @@ class _RedisShardedSubscription(RedisSubscriptionBase): def _get_subscription_type(self) -> str: return "sharded" + @override + def _publish_close_event(self) -> None: + try: + self._client.spublish(self._topic, SIG_CLOSE) # type: ignore[attr-defined,union-attr] + except Exception: + logger.exception("failed to publish close event") + @override def _subscribe(self) -> None: assert self._pubsub is not None diff --git a/api/libs/broadcast_channel/redis/streams_channel.py b/api/libs/broadcast_channel/redis/streams_channel.py index 62e58798ab3..b3385b05388 100644 --- a/api/libs/broadcast_channel/redis/streams_channel.py +++ b/api/libs/broadcast_channel/redis/streams_channel.py @@ -9,6 +9,7 @@ from typing import Self, override from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from libs.broadcast_channel.exc import SubscriptionClosedError +from libs.broadcast_channel.signals import SIG_CLOSE from redis import Redis, RedisCluster logger = logging.getLogger(__name__) @@ -29,20 +30,15 @@ class StreamsBroadcastChannel: redis_client: Redis | RedisCluster, *, retention_seconds: int = 600, - join_timeout_ms: int = 2000, ): self._client = redis_client self._retention_seconds = max(int(retention_seconds or 0), 0) - # Max time close() will wait for the listener thread to finish. - # See `_StreamsSubscription._join_timeout_ms` for the rationale. - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) def topic(self, topic: str) -> StreamsTopic: return StreamsTopic( self._client, topic, retention_seconds=self._retention_seconds, - join_timeout_ms=self._join_timeout_ms, ) @@ -53,13 +49,11 @@ class StreamsTopic: topic: str, *, retention_seconds: int = 600, - join_timeout_ms: int = 2000, ): self._client = redis_client self._topic = topic self._key = serialize_redis_name(f"stream:{topic}") self._retention_seconds = retention_seconds - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) self.max_length = 5000 def as_producer(self) -> Producer: @@ -77,23 +71,15 @@ class StreamsTopic: return self def subscribe(self) -> Subscription: - return _StreamsSubscription(self._client, self._key, join_timeout_ms=self._join_timeout_ms) + return _StreamsSubscription(self._client, self._key) class _StreamsSubscription(Subscription): _SENTINEL = object() - def __init__(self, client: Redis | RedisCluster, key: str, *, join_timeout_ms: int = 2000): + def __init__(self, client: Redis | RedisCluster, key: str): self._client = client self._key = key - # Max time close() will wait for the listener thread to finish before - # returning. Bounds SSE close tail latency: the listener blocks on - # XREAD with BLOCK=1000ms, so close() naturally waits up to ~1s for - # the thread to notice _closed. Setting this lower lets close() - # return promptly while the daemon listener exits on its own within - # one BLOCK window - safe because the listener holds no critical - # state. ``0`` means close() does not wait at all. - self._join_timeout_ms = max(int(join_timeout_ms or 0), 0) self._queue: queue.Queue[object] = queue.Queue() @@ -106,7 +92,6 @@ class _StreamsSubscription(Subscription): # reading and writing the _listener / `_closed` attribute. self._lock = threading.Lock() self._closed: bool = False - # self._closed = threading.Event() self._listener: threading.Thread | None = None def _listen(self) -> None: @@ -144,6 +129,8 @@ class _StreamsSubscription(Subscription): case bytes() | bytearray(): data_bytes = bytes(data) if data_bytes is not None: + if data_bytes == SIG_CLOSE: + break self._queue.put_nowait(data_bytes) last_id = entry_id finally: @@ -203,6 +190,13 @@ class _StreamsSubscription(Subscription): assert isinstance(item, (bytes, bytearray)), "Unexpected item type in stream queue" return bytes(item) + def _publish_close_event(self) -> None: + """Publish an empty message to the stream to unblock the listener's xread.""" + try: + self._client.xadd(self._key, {b"data": SIG_CLOSE}) + except Exception: + logger.exception("failed to publish close event") + @override def close(self) -> None: with self._lock: @@ -212,16 +206,17 @@ class _StreamsSubscription(Subscription): listener = self._listener if listener is not None: self._listener = None - # We close the listener outside of the with block to avoid holding the - # lock for a long time. + + if listener is not None: + self._publish_close_event() + if listener is not None and listener.is_alive(): - listener.join(timeout=self._join_timeout_ms / 1000.0) + listener.join(timeout=2) if listener.is_alive(): logger.debug( - "Streams subscription listener for key %s did not stop within %dms; " + "Streams subscription listener for key %s did not stop after join; " "daemon thread will exit on its own within one poll window.", self._key, - self._join_timeout_ms, ) # Context manager helpers diff --git a/api/libs/broadcast_channel/signals.py b/api/libs/broadcast_channel/signals.py new file mode 100644 index 00000000000..812a0beb9af --- /dev/null +++ b/api/libs/broadcast_channel/signals.py @@ -0,0 +1 @@ +SIG_CLOSE = b"__closed__" diff --git a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py new file mode 100644 index 00000000000..9dc85d2a89b --- /dev/null +++ b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py @@ -0,0 +1,39 @@ +"""agent drive skill metadata refactor + +Revision ID: b2515f9d4c2a +Revises: 4f7b2c8d9a10 +Create Date: 2026-06-18 23:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "b2515f9d4c2a" +down_revision = "4f7b2c8d9a10" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "agent_drive_files", + sa.Column("is_skill", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + op.add_column( + "agent_drive_files", + sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True), + ) + op.create_index( + "agent_drive_files_tenant_agent_is_skill_key_idx", + "agent_drive_files", + ["tenant_id", "agent_id", "is_skill", "key"], + ) + + +def downgrade() -> None: + op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files") + op.drop_column("agent_drive_files", "skill_metadata") + op.drop_column("agent_drive_files", "is_skill") diff --git a/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py new file mode 100644 index 00000000000..213b8d36978 --- /dev/null +++ b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py @@ -0,0 +1,66 @@ +"""add agent debug conversations + +Revision ID: c8f4a6b2d3e1 +Revises: b2515f9d4c2a +Create Date: 2026-06-22 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "c8f4a6b2d3e1" +down_revision = "b2515f9d4c2a" +branch_labels = None +depends_on = None + + +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column: + kwargs = {"nullable": nullable, "primary_key": primary_key} + if primary_key and _is_pg(op.get_bind()): + kwargs["server_default"] = sa.text("uuidv7()") + return sa.Column(name, models.types.StringUUID(), **kwargs) + + +def upgrade(): + op.create_table( + "agent_debug_conversations", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=False), + sa.Column("conversation_id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "account_id", + name=op.f("agent_debug_conversation_agent_account_unique"), + ), + ) + op.create_index( + "agent_debug_conversation_conversation_idx", + "agent_debug_conversations", + ["conversation_id"], + ) + op.create_index( + "agent_debug_conversation_account_idx", + "agent_debug_conversations", + ["tenant_id", "account_id"], + ) + + +def downgrade(): + op.drop_index("agent_debug_conversation_account_idx", table_name="agent_debug_conversations") + op.drop_index("agent_debug_conversation_conversation_idx", table_name="agent_debug_conversations") + op.drop_table("agent_debug_conversations") diff --git a/api/models/__init__.py b/api/models/__init__.py index 78ca43fa374..9992de982c4 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -13,6 +13,7 @@ from .agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentDriveFile, AgentDriveFileKind, AgentIconType, @@ -156,6 +157,7 @@ __all__ = [ "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", + "AgentDebugConversation", "AgentDriveFile", "AgentDriveFileKind", "AgentIconType", diff --git a/api/models/agent.py b/api/models/agent.py index 1a13ccde77b..46044edd5e7 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -83,6 +83,8 @@ class AgentConfigRevisionOperation(StrEnum): SAVE_NEW_AGENT = "save_new_agent" # Promotes a workflow-only Agent into the reusable Agent Roster. SAVE_TO_ROSTER = "save_to_roster" + # Switches the Agent's current published config back to an existing version. + RESTORE_VERSION = "restore_version" class WorkflowAgentBindingType(StrEnum): @@ -180,6 +182,34 @@ class Agent(DefaultFieldsMixin, Base): archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) +class AgentDebugConversation(DefaultFieldsMixin, Base): + """Per-account 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. + """ + + __tablename__ = "agent_debug_conversations" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_debug_conversation_pkey"), + UniqueConstraint( + "tenant_id", + "agent_id", + "account_id", + name="agent_debug_conversation_agent_account_unique", + ), + Index("agent_debug_conversation_conversation_idx", "conversation_id"), + Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + 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) + conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + class AgentConfigSnapshot(DefaultFieldsMixin, Base): """Immutable Agent Soul snapshot. @@ -430,14 +460,17 @@ class AgentDriveFile(DefaultFieldsMixin, Base): synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values (created by the agent runtime or Skill standardization, not shared with other business records) have their storage object + record deleted when the KV entry is - overwritten or removed; otherwise only the KV row is dropped. Lifecycle never relies - on ``UploadFile.used/used_by`` (not a reliable refcount). + overwritten or removed; otherwise only the KV row is dropped. Skills are represented + by the canonical ``/SKILL.md`` row with ``is_skill=True`` and a serialized + ``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by`` + (not a reliable refcount). """ __tablename__ = "agent_drive_files" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"), UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"), + Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"), ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) @@ -453,6 +486,8 @@ class AgentDriveFile(DefaultFieldsMixin, Base): value_owned_by_drive: Mapped[bool] = mapped_column( sa.Boolean, nullable=False, default=False, server_default=sa.text("false") ) + is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false")) + skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True) size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True) hash: Mapped[str | None] = mapped_column(String(255), nullable=True) mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/api/models/human_input.py b/api/models/human_input.py index d11274bc921..b84579a4e09 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -134,20 +134,40 @@ class HumanInputDelivery(DefaultFieldsMixin, Base): ) +class ApprovalChannel(StrEnum): + """Where a paused human input form can be approved, surfaced to API callers.""" + + EMAIL = "email" + WEB_APP = "web_app" + CONSOLE = "console" + + class RecipientType(StrEnum): - # EMAIL_MEMBER member means that the - EMAIL_MEMBER = "email_member" - EMAIL_EXTERNAL = "email_external" + # Second value = the approval channel this recipient maps to (surfaced in `approval_channels`). + EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL + EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL # STANDALONE_WEB_APP is used by the standalone web app. # # It's not used while running workflows / chatflows containing HumanInput # node inside console. - STANDALONE_WEB_APP = "standalone_web_app" + STANDALONE_WEB_APP = "standalone_web_app", ApprovalChannel.WEB_APP # CONSOLE is used while running workflows / chatflows containing HumanInput # node inside console. (E.G. running installed apps or debugging workflows / chatflows) - CONSOLE = "console" + CONSOLE = "console", ApprovalChannel.CONSOLE # BACKSTAGE is used for backstage input inside console. - BACKSTAGE = "backstage" + BACKSTAGE = "backstage", ApprovalChannel.CONSOLE + + _approval_channel: ApprovalChannel + + def __new__(cls, value: str, approval_channel: ApprovalChannel) -> "RecipientType": + member = str.__new__(cls, value) + member._value_ = value + member._approval_channel = approval_channel + return member + + @property + def approval_channel(self) -> ApprovalChannel: + return self._approval_channel @final diff --git a/api/models/model.py b/api/models/model.py index 4c73385f3aa..947cbf6fe4a 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -774,26 +774,7 @@ class AppModelConfig(TypeBase): @property def annotation_reply_dict(self) -> AnnotationReplyConfig: - annotation_setting = db.session.scalar( - select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == self.app_id) - ) - if annotation_setting: - collection_binding_detail = annotation_setting.collection_binding_detail - if not collection_binding_detail: - raise ValueError("Collection binding detail not found") - - return { - "id": annotation_setting.id, - "enabled": True, - "score_threshold": annotation_setting.score_threshold, - "embedding_model": { - "embedding_provider_name": collection_binding_detail.provider_name, - "embedding_model_name": collection_binding_detail.model_name, - }, - } - - else: - return {"enabled": False} + return load_annotation_reply_config(db.session(), self.app_id) @property def more_like_this_dict(self) -> EnabledConfig: @@ -864,7 +845,7 @@ class AppModelConfig(TypeBase): }, ) - def to_dict(self) -> AppModelConfigDict: + def to_dict(self, *, annotation_reply: AnnotationReplyConfig | None = None) -> AppModelConfigDict: return { "opening_statement": self.opening_statement, "suggested_questions": self.suggested_questions_list, @@ -872,7 +853,7 @@ class AppModelConfig(TypeBase): "speech_to_text": self.speech_to_text_dict, "text_to_speech": self.text_to_speech_dict, "retriever_resource": self.retriever_resource_dict, - "annotation_reply": self.annotation_reply_dict, + "annotation_reply": annotation_reply if annotation_reply is not None else self.annotation_reply_dict, "more_like_this": self.more_like_this_dict, "sensitive_word_avoidance": self.sensitive_word_avoidance_dict, "external_data_tools": self.external_data_tools_list, @@ -2038,6 +2019,30 @@ class AppAnnotationSetting(TypeBase): ) +def load_annotation_reply_config(session: Session, app_id: str) -> AnnotationReplyConfig: + annotation_setting = session.scalar(select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app_id)) + if annotation_setting is None: + return {"enabled": False} + + from .dataset import DatasetCollectionBinding + + collection_binding_detail = session.scalar( + select(DatasetCollectionBinding).where(DatasetCollectionBinding.id == annotation_setting.collection_binding_id) + ) + if collection_binding_detail is None: + raise ValueError("Collection binding detail not found") + + return { + "id": annotation_setting.id, + "enabled": True, + "score_threshold": annotation_setting.score_threshold, + "embedding_model": { + "embedding_provider_name": collection_binding_detail.provider_name, + "embedding_model_name": collection_binding_detail.model_name, + }, + } + + class OperationLog(TypeBase): __tablename__ = "operation_logs" __table_args__ = ( diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 123a2e6e04b..ef11a817662 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -391,6 +391,80 @@ Check if activation token is valid | 400 | Invalid request parameters | | | 403 | Insufficient permissions | | +### [GET] /agent/{agent_id}/api-access +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent service API access | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)
| + +### [POST] /agent/{agent_id}/api-enable +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentApiStatusPayload](#agentapistatuspayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent service API status updated | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)
| +| 403 | Insufficient permissions | | + +### [GET] /agent/{agent_id}/api-keys +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent service API keys | **application/json**: [ApiKeyList](#apikeylist)
| + +### [POST] /agent/{agent_id}/api-keys +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Agent service API key created | **application/json**: [ApiKeyItem](#apikeyitem)
| +| 400 | Maximum keys exceeded | | + +### [DELETE] /agent/{agent_id}/api-keys/{api_key_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | +| api_key_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Agent service API key deleted | + ### [GET] /agent/{agent_id}/chat-messages Get Agent App chat messages for a conversation with pagination @@ -576,6 +650,37 @@ Truncated text preview of one Agent App drive value | ---- | ----------- | ------ | | 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| +### [GET] /agent/{agent_id}/drive/skills +List drive-backed skills for an Agent App + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
| + +### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect +Inspect one drive-backed skill for slash-menu hover/detail UI + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
| + ### [POST] /agent/{agent_id}/features Update an Agent App's presentation features (opener, follow-up, citations, ...) @@ -905,6 +1010,20 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | ---- | ----------- | ------ | | 200 | Agent version detail | **application/json**: [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse)
| +### [POST] /agent/{agent_id}/versions/{version_id}/restore +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | +| version_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent version restored | **application/json**: [AgentConfigSnapshotRestoreResponse](#agentconfigsnapshotrestoreresponse)
| + ### [GET] /all-workspaces #### Parameters @@ -1454,6 +1573,40 @@ Truncated text preview of one drive value (binary-safe; SKILL.md is the main cas | ---- | ----------- | ------ | | 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| +### [GET] /apps/{app_id}/agent/drive/skills +List drive-backed skills for the bound agent + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)
| + +### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect +Inspect one drive-backed skill for slash-menu hover/detail UI + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string (uuid) | +| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
| + ### [DELETE] /apps/{app_id}/agent/files Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) @@ -11954,6 +12107,31 @@ Default namespace | chat_prompt_config | object | | No | | completion_prompt_config | object | | No | +#### AgentApiAccessResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_key_count | integer | | Yes | +| api_rph | integer | | Yes | +| api_rpm | integer | | Yes | +| chat_endpoint | string | | Yes | +| conversations_endpoint | string | | Yes | +| enabled | boolean | | Yes | +| files_upload_endpoint | string | | Yes | +| info_endpoint | string | | Yes | +| messages_endpoint | string | | Yes | +| meta_endpoint | string | | Yes | +| parameters_endpoint | string | | Yes | +| service_api_base_url | string | | Yes | +| stop_endpoint | string | | Yes | +| streaming_only | boolean,
**Default:** true | | No | + +#### AgentApiStatusPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enable_api | boolean | Enable or disable Agent service API | Yes | + #### AgentAppComposerResponse | Name | Type | Description | Required | @@ -11987,6 +12165,7 @@ Default namespace | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | +| debug_conversation_id | string | | No | | deleted_tools | [ [DeletedTool](#deletedtool) ] | | No | | description | string | | No | | enable_api | boolean | | Yes | @@ -12050,6 +12229,7 @@ default (the config form sends the full desired feature state on save). | create_user_name | string | | No | | created_at | integer | | No | | created_by | string | | No | +| debug_conversation_id | string | | No | | description | string | | No | | has_draft_trigger | boolean | | No | | icon | string | | No | @@ -12340,6 +12520,13 @@ Audit operation recorded for Agent Soul version/revision changes. | ---- | ---- | ----------- | -------- | | data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes | +#### AgentConfigSnapshotRestoreResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| active_config_snapshot_id | string | | Yes | +| result | string | | Yes | + #### AgentConfigSnapshotSummaryResponse | Name | Type | Description | Required | @@ -12425,9 +12612,11 @@ Audit operation recorded for Agent Soul version/revision changes. | created_at | integer | | No | | file_kind | string | | Yes | | hash | string | | No | +| is_skill | boolean | | No | | key | string | | Yes | | mime_type | string | | No | | size | integer | | No | +| skill_metadata | string | | No | #### AgentDriveListResponse @@ -12445,6 +12634,65 @@ Audit operation recorded for Agent Soul version/revision changes. | text | string | | No | | truncated | boolean | | Yes | +#### AgentDriveSkillFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| available_in_drive | boolean | | Yes | +| drive_key | string | | No | +| name | string | | Yes | +| path | string | | Yes | +| type | string | | Yes | + +#### AgentDriveSkillInspectResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archive_key | string | | No | +| created_at | integer | | No | +| description | string | | Yes | +| file_tree | [ object ] | | No | +| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No | +| hash | string | | No | +| mime_type | string | | No | +| name | string | | Yes | +| path | string | | Yes | +| size | integer | | No | +| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes | +| skill_md_key | string | | Yes | +| source | string | | Yes | +| warnings | [ string ] | | No | + +#### AgentDriveSkillItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archive_key | string | | No | +| created_at | integer | | No | +| description | string | | Yes | +| hash | string | | No | +| mime_type | string | | No | +| name | string | | Yes | +| path | string | | Yes | +| size | integer | | No | +| skill_md_key | string | | Yes | + +#### AgentDriveSkillListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No | + +#### AgentDriveSkillMarkdownResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| key | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + #### AgentEnvVariableConfig | Name | Type | Description | Required | diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index ce0150e8e88..bd93557edcf 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -83,7 +83,6 @@ User-scoped operations | mode | query | | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" | | name | query | | No | string | | page | query | | No | integer,
**Default:** 1 | -| tag | query | | No | string | | workspace_id | query | | Yes | string | #### Responses @@ -331,6 +330,22 @@ Upload a file to use as an input variable when running the app | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /permitted-external-apps/{app_id}/describe +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| fields | query | | No | string | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Permitted external app description | **application/json**: [AppDescribeResponse](#appdescriberesponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [GET] /workspaces #### Responses @@ -507,14 +522,12 @@ Upload a file to use as an input variable when running the app | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author | string | | No | | description | string | | No | | id | string | | Yes | | is_agent | boolean | | No | | mode | string | | Yes | | name | string | | Yes | | service_api_enabled | boolean | | Yes | -| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | | updated_at | string | | No | #### AppDescribeQuery @@ -568,16 +581,14 @@ Request body for POST /workspaces//apps/imports. | yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No | | yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No | -#### AppInfoResponse +#### AppInfo | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author | string | | No | | description | string | | No | | id | string | | Yes | | mode | string | | Yes | | name | string | | Yes | -| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | #### AppListQuery @@ -589,7 +600,6 @@ mode is a closed enum. | mode | [AppMode](#appmode) | | No | | name | string | | No | | page | integer,
**Default:** 1 | | No | -| tag | string | | No | | workspace_id | string | | Yes | #### AppListResponse @@ -606,12 +616,10 @@ mode is a closed enum. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_by_name | string | | No | | description | string | | No | | id | string | | Yes | | mode | [AppMode](#appmode) | | Yes | | name | string | | Yes | -| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | | updated_at | string | | No | | workspace_id | string | | No | | workspace_name | string | | No | @@ -982,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | last_used_at | string | | No | | prefix | string | | Yes | -#### TagItem - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| name | string | | Yes | - #### TaskStopResponse 200 body for POST /apps//tasks//stop. The handler always returns diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py index 12f91212c1f..797134b3619 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py @@ -1,3 +1,4 @@ +import logging import time import uuid from datetime import datetime @@ -142,9 +143,8 @@ class TestTraceClient: mock_notify.assert_called_once() @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - @patch("dify_trace_aliyun.data_exporter.traceclient.logger") def test_add_span_queue_full( - self, mock_logger: MagicMock, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient] + self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient], caplog: pytest.LogCaptureFixture ): client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint", max_queue_size=1) @@ -164,12 +164,15 @@ class TestTraceClient: client.add_span(span_data) assert len(client.queue) == 1 - client.add_span(span_data) - assert len(client.queue) == 1 - mock_logger.warning.assert_called_with("Queue is full, likely spans will be dropped.") + with caplog.at_level(logging.WARNING): + client.add_span(span_data) + assert len(client.queue) == 1 + assert "Queue is full, likely spans will be dropped." in caplog.text @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_export_batch_error(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): + def test_export_batch_error( + self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient], caplog: pytest.LogCaptureFixture + ): mock_exporter = mock_exporter_class.return_value mock_exporter.export.side_effect = Exception("Export failed") @@ -177,9 +180,9 @@ class TestTraceClient: mock_span = MagicMock(spec=ReadableSpan) client.queue.append(mock_span) - with patch("dify_trace_aliyun.data_exporter.traceclient.logger") as mock_logger: + with caplog.at_level(logging.WARNING): client._export_batch() - mock_logger.warning.assert_called() + assert "Error exporting spans" in caplog.text @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") def test_worker_loop(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): diff --git a/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py index 30646815d83..0e1f33b437d 100644 --- a/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py +++ b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py @@ -307,13 +307,12 @@ class TestGetProjectUrl: monkeypatch.setattr(trace_instance, "entity", None) monkeypatch.setattr(trace_instance, "project_name", None) # Force an error by making string formatting fail - with patch("dify_trace_weave.weave_trace.logger") as mock_logger: - # Simulate exception via property - original_entity = trace_instance.entity - trace_instance.entity = None - trace_instance.project_name = None - url = trace_instance.get_project_url() - assert "https://wandb.ai/" in url + # Simulate exception via property + original_entity = trace_instance.entity + trace_instance.entity = None + trace_instance.project_name = None + url = trace_instance.get_project_url() + assert "https://wandb.ai/" in url # ── TestTraceDispatcher ───────────────────────────────────────────────────── diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 16ab3627929..af6be0abfa6 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -830,6 +830,16 @@ class AgentComposerService: ) -> WorkflowAgentNodeBinding: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: + if cls._is_start_from_scratch_request(binding=binding, payload=payload): + return cls._switch_roster_binding_to_inline_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + account_id=account_id, + binding=binding, + payload=payload, + ) binding.node_job_config = node_job if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: current_snapshot = cls._require_version( @@ -880,6 +890,46 @@ class AgentComposerService: db.session.flush() return binding + @classmethod + def _is_start_from_scratch_request(cls, *, binding: WorkflowAgentNodeBinding, payload: ComposerSavePayload) -> bool: + return ( + binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + and payload.binding is not None + and payload.binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT.value + ) + + @classmethod + def _switch_roster_binding_to_inline_agent( + cls, + *, + tenant_id: str, + app_id: str, + workflow_id: str, + node_id: str, + account_id: str, + binding: WorkflowAgentNodeBinding, + payload: ComposerSavePayload, + ) -> WorkflowAgentNodeBinding: + if payload.binding and (payload.binding.agent_id or payload.binding.current_snapshot_id): + raise ValueError("Start from Scratch must not provide an existing inline agent binding.") + + agent_soul = payload.agent_soul or AgentSoulConfig() + agent = cls._create_workflow_only_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow_id, + node_id=node_id, + account_id=account_id, + agent_soul=agent_soul, + ) + binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + binding.agent_id = agent.id + binding.current_snapshot_id = agent.active_config_snapshot_id + binding.node_job_config = payload.node_job or binding.node_job_config + binding.updated_by = account_id + db.session.flush() + return binding + @classmethod def _save_to_current_version( cls, diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index ca8428b4f7c..e78d49c65b7 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -3,6 +3,7 @@ from typing import Any, TypedDict from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError +from core.app.entities.app_invoke_entities import InvokeFrom from libs.datetime_utils import naive_utc_now from libs.helper import to_timestamp from models.agent import ( @@ -10,6 +11,7 @@ from models.agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentKind, AgentScope, AgentSource, @@ -18,8 +20,8 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig -from models.enums import AppStatus -from models.model import App, AppMode, IconType +from models.enums import AppStatus, ConversationFromSource, ConversationStatus +from models.model import App, AppMode, Conversation, IconType from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -96,6 +98,7 @@ class AgentRosterService: "scope": agent.scope.value, "source": agent.source.value, "app_id": agent.app_id, + "debug_conversation_id": None, "workflow_id": agent.workflow_id, "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, @@ -392,8 +395,126 @@ class AgentRosterService: agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig()) self._session.flush() + self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) return agent + def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str: + """Create one console debug conversation for an Agent App editor.""" + + conversation = Conversation( + app_id=app_id, + app_model_config_id=None, + model_provider=None, + model_id="", + override_model_configs=None, + mode=AppMode.AGENT, + name="Agent Debugging Conversation", + inputs={}, + introduction="", + system_instruction="", + system_instruction_tokens=0, + status=ConversationStatus.NORMAL, + invoke_from=InvokeFrom.DEBUGGER, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=account_id, + ) + self._session.add(conversation) + self._session.flush() + return conversation.id + + def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: + if not agent.app_id: + raise AgentNotFoundError() + + mapping = self._session.scalar( + select(AgentDebugConversation).where( + AgentDebugConversation.tenant_id == agent.tenant_id, + AgentDebugConversation.agent_id == agent.id, + AgentDebugConversation.account_id == account_id, + ) + ) + if mapping is not None: + conversation_id = self._session.scalar( + select(Conversation.id).where( + Conversation.id == mapping.conversation_id, + Conversation.app_id == agent.app_id, + Conversation.from_source == ConversationFromSource.CONSOLE, + Conversation.from_account_id == account_id, + Conversation.is_deleted.is_(False), + ) + ) + if conversation_id: + return conversation_id + + mapping.conversation_id = self._create_agent_app_debug_conversation( + app_id=agent.app_id, + account_id=account_id, + ) + self._session.flush() + return mapping.conversation_id + + conversation_id = self._create_agent_app_debug_conversation( + app_id=agent.app_id, + account_id=account_id, + ) + self._session.add( + AgentDebugConversation( + tenant_id=agent.tenant_id, + agent_id=agent.id, + app_id=agent.app_id, + account_id=account_id, + conversation_id=conversation_id, + ) + ) + self._session.flush() + 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 + ) -> str: + """Return the current editor's debug conversation for an Agent App.""" + + agent = self._session.scalar( + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + ) + if agent is None: + raise AgentNotFoundError() + + conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + if commit: + self._session.commit() + return conversation_id + + def load_or_create_agent_app_debug_conversation_ids_by_agent_id( + self, *, tenant_id: str, agents: list[Agent], account_id: str + ) -> dict[str, str]: + """Return per-account debug conversations for a page of Agent Apps.""" + + conversation_ids_by_agent_id: dict[str, str] = {} + changed = False + for agent in agents: + if ( + agent.tenant_id != tenant_id + or agent.scope != AgentScope.ROSTER + or agent.source != AgentSource.AGENT_APP + ): + continue + conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + ) + changed = True + if changed: + self._session.commit() + return conversation_ids_by_agent_id + def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]: """Return active app-backed Agents keyed by Agent App id.""" if not app_ids: @@ -666,12 +787,16 @@ class AgentRosterService: @staticmethod def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: if agent.source == AgentSource.AGENT_APP: - return {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + return { + AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.RESTORE_VERSION, + } return { AgentConfigRevisionOperation.CREATE_VERSION, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_NEW_AGENT, AgentConfigRevisionOperation.SAVE_TO_ROSTER, + AgentConfigRevisionOperation.RESTORE_VERSION, } def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: @@ -764,6 +889,46 @@ class AgentRosterService: ] return result + def restore_agent_version( + self, *, tenant_id: str, agent_id: str, version_id: str, account_id: str + ) -> dict[str, Any]: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent) + visible_version_id = self._session.scalar( + select(AgentConfigSnapshot.id) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == version_id, + AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)), + ) + .limit(1) + ) + if not visible_version_id: + raise AgentVersionNotFoundError() + + version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) + if agent.active_config_snapshot_id == version.id: + return {"result": "success", "active_config_snapshot_id": version.id} + + previous_snapshot_id = agent.active_config_snapshot_id + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(version.config_snapshot) + agent.updated_by = account_id + self._session.add( + AgentConfigRevision( + tenant_id=tenant_id, + agent_id=agent_id, + previous_snapshot_id=previous_snapshot_id, + current_snapshot_id=version.id, + revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id), + operation=AgentConfigRevisionOperation.RESTORE_VERSION, + created_by=account_id, + ) + ) + self._session.commit() + return {"result": "success", "active_config_snapshot_id": version.id} + def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent: stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id) if roster_only: @@ -789,6 +954,17 @@ class AgentRosterService: raise AgentVersionNotFoundError() return version + def _next_revision(self, *, tenant_id: str, agent_id: str) -> int: + return ( + self._session.scalar( + select(func.max(AgentConfigRevision.revision)).where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + ) + ) + or 0 + ) + 1 + def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]: predicates = [ and_( diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index b83004f3c4b..3fbcb81e61f 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -23,7 +23,7 @@ from typing import Any from core.tools.tool_file_manager import ToolFileManager from models.agent_config_entities import AgentSkillRefConfig from services.agent.skill_package_service import SkillPackageService -from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef +from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata _FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" _SKILL_MD_NAME = "SKILL.md" @@ -91,6 +91,12 @@ class SkillStandardizeService: key=skill_md_key, file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id), value_owned_by_drive=True, + is_skill=True, + skill_metadata=DriveSkillMetadata( + name=manifest.name, + description=manifest.description, + manifest_files=manifest.files, + ), ), DriveCommitItem( key=archive_key, diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index bb3f8ca69e3..62b6056412e 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -17,12 +17,14 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane: from __future__ import annotations +import json import logging import re import urllib.parse -from typing import Any, Literal +from typing import Any, Literal, TypedDict +from urllib.parse import unquote -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import func, select from sqlalchemy.exc import DataError, SQLAlchemyError from sqlalchemy.orm import Session @@ -41,6 +43,8 @@ logger = logging.getLogger(__name__) _MAX_KEY_LENGTH = 512 _DRIVE_REF_PREFIX = "agent-" +_SKILL_MD_SUFFIX = "/SKILL.md" +_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" class AgentDriveError(Exception): @@ -58,16 +62,86 @@ class AgentDriveError(Exception): class DriveFileRef(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: Literal["upload_file", "tool_file"] id: str +class DriveSkillMetadata(BaseModel): + """Validated skill catalog metadata stored as a JSON string on the drive row.""" + + model_config = ConfigDict(extra="forbid") + + name: str + description: str = "" + # Safe archive member paths captured during skill standardization. The drive + # stores only canonical SKILL.md + full archive, so the UI uses this manifest + # to show the original uploaded package contents. + manifest_files: list[str] | None = None + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("skill metadata name must not be blank") + return normalized + + class DriveCommitItem(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str file_ref: DriveFileRef # Drive-owned values may be physically cleaned on overwrite/removal; refs to # files shared with other business records should set this False. value_owned_by_drive: bool = True + is_skill: bool = False + skill_metadata: DriveSkillMetadata | None = None + + +class AgentDriveSkillInfo(TypedDict): + path: str + skill_md_key: str + archive_key: str | None + name: str + description: str + size: int | None + mime_type: str | None + hash: str | None + created_at: int | None + + +class AgentDriveSkillFileInfo(TypedDict): + path: str + name: str + type: str + drive_key: str | None + available_in_drive: bool + + +class AgentDriveSkillInspectInfo(TypedDict): + path: str + skill_md_key: str + archive_key: str | None + name: str + description: str + size: int | None + mime_type: str | None + hash: str | None + created_at: int | None + source: str + files: list[AgentDriveSkillFileInfo] + file_tree: list[dict[str, Any]] + skill_md: dict[str, Any] + warnings: list[str] + + +def decode_drive_mention_ref(ref_id: str) -> str: + """Decode the prompt token's URL-encoded drive-key field.""" + + return unquote(ref_id or "") def parse_agent_drive_ref(drive_ref: str) -> str: @@ -132,6 +206,8 @@ class AgentDriveService: "mime_type": row.mime_type, "file_kind": row.file_kind.value, "file_id": row.file_id, + "is_skill": row.is_skill, + "skill_metadata": row.skill_metadata, "created_at": int(row.created_at.timestamp()) if row.created_at else None, } if include_download_url: @@ -217,6 +293,87 @@ class AgentDriveService: self._delete_storage(storage_key) return removed_keys + def list_skills(self, *, tenant_id: str, agent_id: str) -> list[AgentDriveSkillInfo]: + """Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows.""" + + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + skill_rows = list( + session.scalars( + select(AgentDriveFile) + .where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.is_skill.is_(True), + ) + .order_by(AgentDriveFile.key) + ) + ) + archive_keys = set( + session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key.in_([self._skill_archive_key(row.key) for row in skill_rows]), + ) + ) + ) + + skills: list[AgentDriveSkillInfo] = [] + for row in skill_rows: + metadata = self._parse_skill_metadata(row.key, row.skill_metadata) + archive_key = self._skill_archive_key(row.key) + skills.append( + { + "path": self._skill_path_from_key(row.key), + "skill_md_key": row.key, + "archive_key": archive_key if archive_key in archive_keys else None, + "name": metadata.name, + "description": metadata.description, + "size": row.size, + "mime_type": row.mime_type, + "hash": row.hash, + "created_at": int(row.created_at.timestamp()) if row.created_at else None, + } + ) + return skills + + def inspect_skill(self, *, tenant_id: str, agent_id: str, skill_path: str) -> AgentDriveSkillInspectInfo: + """Return the UI-facing skill inspect view for slash-menu hover/detail.""" + + skill_path = normalize_drive_key(skill_path) + skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}" + skill_path = self._skill_path_from_key(skill_md_key) + catalog = next( + (item for item in self.list_skills(tenant_id=tenant_id, agent_id=agent_id) if item["path"] == skill_path), + None, + ) + if catalog is None: + raise AgentDriveError("skill_not_found", "no drive-backed skill for this path", status_code=404) + + manifest_files = self._manifest_files_from_skill_metadata( + tenant_id=tenant_id, + agent_id=agent_id, + skill_md_key=skill_md_key, + ) + drive_items = self.manifest(tenant_id=tenant_id, agent_id=agent_id, prefix=f"{skill_path}/") + drive_keys = {item["key"] for item in drive_items} + preview = self.preview(tenant_id=tenant_id, agent_id=agent_id, key=skill_md_key) + files, warnings = self._skill_file_entries( + skill_path=skill_path, + skill_md_key=skill_md_key, + manifest_files=manifest_files, + drive_keys=drive_keys, + ) + return { + **catalog, + "source": "skill_md", + "files": files, + "file_tree": self._build_file_tree(files), + "skill_md": preview, + "warnings": warnings, + } + def _commit_one( self, session: Session, @@ -228,9 +385,10 @@ class AgentDriveService: pending_storage_deletes: list[str], ) -> dict[str, Any]: key = normalize_drive_key(item.key) + skill_metadata = self._validate_skill_commit_fields(key=key, item=item) file_kind = AgentDriveFileKind(item.file_ref.kind) file_id = item.file_ref.id - size, mime_type = self._validate_source( + size, mime_type, file_hash = self._validate_source( session, tenant_id=tenant_id, user_id=user_id, file_kind=file_kind, file_id=file_id ) @@ -245,6 +403,11 @@ class AgentDriveService: # Idempotent re-commit of the same value: leave it (do not clean). if existing.file_kind == file_kind and existing.file_id == file_id: existing.value_owned_by_drive = item.value_owned_by_drive + existing.is_skill = item.is_skill + existing.skill_metadata = skill_metadata + existing.size = size + existing.mime_type = mime_type + existing.hash = file_hash return self._row_dict(existing) # Overwrite: clean the previous drive-owned value if no longer referenced. if existing.value_owned_by_drive: @@ -259,7 +422,10 @@ class AgentDriveService: existing.file_kind = file_kind existing.file_id = file_id existing.value_owned_by_drive = item.value_owned_by_drive + existing.is_skill = item.is_skill + existing.skill_metadata = skill_metadata existing.size = size + existing.hash = file_hash existing.mime_type = mime_type return self._row_dict(existing) @@ -271,7 +437,10 @@ class AgentDriveService: file_kind=file_kind, file_id=file_id, value_owned_by_drive=item.value_owned_by_drive, + is_skill=item.is_skill, + skill_metadata=skill_metadata, size=size, + hash=file_hash, mime_type=mime_type, created_by=user_id, ) @@ -287,8 +456,187 @@ class AgentDriveService: "size": row.size, "mime_type": row.mime_type, "value_owned_by_drive": row.value_owned_by_drive, + "is_skill": row.is_skill, + "skill_metadata": row.skill_metadata, } + @staticmethod + def _skill_path_from_key(key: str) -> str: + if not key.endswith(_SKILL_MD_SUFFIX): + raise AgentDriveError( + "invalid_skill_key", + "skill rows must use the canonical '/SKILL.md' key", + status_code=500, + ) + path = key[: -len(_SKILL_MD_SUFFIX)] + if not path: + raise AgentDriveError( + "invalid_skill_key", + "skill rows must use the canonical '/SKILL.md' key", + status_code=500, + ) + return path + + @classmethod + def _skill_archive_key(cls, key: str) -> str: + return f"{cls._skill_path_from_key(key)}/{_SKILL_ARCHIVE_NAME}" + + @classmethod + def _validate_skill_commit_fields(cls, *, key: str, item: DriveCommitItem) -> str | None: + if not item.is_skill: + if item.skill_metadata is not None: + raise AgentDriveError( + "invalid_skill_metadata", + "skill metadata is only allowed for canonical skill rows", + status_code=400, + ) + return None + cls._skill_path_from_key(key) + if item.skill_metadata is None: + raise AgentDriveError( + "invalid_skill_metadata", + "skill metadata is required for canonical skill rows", + status_code=400, + ) + return json.dumps( + item.skill_metadata.model_dump(mode="json", exclude_none=True), + separators=(",", ":"), + sort_keys=True, + ) + + @staticmethod + def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata: + if raw_metadata is None: + raise AgentDriveError( + "invalid_skill_metadata", + f"skill row '{key}' is missing required metadata", + status_code=500, + ) + try: + return DriveSkillMetadata.model_validate(json.loads(raw_metadata)) + except (ValueError, TypeError) as exc: + raise AgentDriveError( + "invalid_skill_metadata", + f"skill row '{key}' has invalid stored metadata", + status_code=500, + ) from exc + + @staticmethod + def _manifest_files_from_skill_metadata(*, tenant_id: str, agent_id: str, skill_md_key: str) -> list[str] | None: + with session_factory.create_session() as session: + row = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == skill_md_key, + AgentDriveFile.is_skill.is_(True), + ) + ) + if row is None: + return None + try: + metadata = AgentDriveService._parse_skill_metadata(row.key, row.skill_metadata) + except Exception: + logger.warning("drive skill inspect: malformed skill metadata for %s", skill_md_key, exc_info=True) + return None + return [str(item) for item in (metadata.manifest_files or []) if str(item).strip()] or None + + @classmethod + def _skill_file_entries( + cls, + *, + skill_path: str, + skill_md_key: str, + manifest_files: list[str] | None, + drive_keys: set[str], + ) -> tuple[list[AgentDriveSkillFileInfo], list[str]]: + warnings: list[str] = [] + if manifest_files: + paths = sorted({normalize_drive_key(path) for path in manifest_files}) + else: + paths = sorted( + { + key.removeprefix(f"{skill_path}/") + for key in drive_keys + if not key.endswith(f"/{_SKILL_ARCHIVE_NAME}") + } + ) + warnings.append("manifest_files_unavailable") + + files: list[AgentDriveSkillFileInfo] = [] + for path in paths: + if path == _SKILL_ARCHIVE_NAME: + continue + drive_key = f"{skill_path}/{path}" + files.append( + { + "path": path, + "name": path.rsplit("/", 1)[-1], + "type": "file", + "drive_key": drive_key if drive_key in drive_keys else None, + "available_in_drive": drive_key in drive_keys, + } + ) + if "SKILL.md" not in {file["path"] for file in files}: + files.insert( + 0, + { + "path": "SKILL.md", + "name": "SKILL.md", + "type": "file", + "drive_key": skill_md_key, + "available_in_drive": skill_md_key in drive_keys, + }, + ) + return files, warnings + + @staticmethod + def _build_file_tree(files: list[AgentDriveSkillFileInfo]) -> list[dict[str, Any]]: + root: dict[str, Any] = {} + for file in files: + cursor = root + parts = [part for part in file["path"].split("/") if part] + path_parts: list[str] = [] + for part in parts[:-1]: + path_parts.append(part) + directory = cursor.setdefault( + part, + { + "name": part, + "path": "/".join(path_parts), + "type": "directory", + "children": {}, + }, + ) + cursor = directory["children"] + leaf_name = parts[-1] if parts else file["name"] + cursor[leaf_name] = { + "name": leaf_name, + "path": file["path"], + "type": file["type"], + "drive_key": file["drive_key"], + "available_in_drive": file["available_in_drive"], + } + + def serialize(node: dict[str, Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in sorted(node.values(), key=lambda value: (value["type"] != "directory", value["name"])): + if item["type"] == "directory": + children = serialize(item["children"]) + result.append( + { + "name": item["name"], + "path": item["path"], + "type": "directory", + "children": children, + } + ) + else: + result.append(item) + return result + + return serialize(root) + @staticmethod def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None: try: @@ -309,7 +657,7 @@ class AgentDriveService: user_id: str, file_kind: AgentDriveFileKind, file_id: str, - ) -> tuple[int | None, str | None]: + ) -> tuple[int | None, str | None, str | None]: """Verify the source file exists for the tenant (and user, for ToolFile). Malformed ids (e.g. a non-UUID hitting a UUID column) are treated as a @@ -328,7 +676,7 @@ class AgentDriveService: raise AgentDriveError( "source_not_found", "source ToolFile not found for this tenant/user", status_code=404 ) - return tool_file.size, tool_file.mimetype + return tool_file.size, tool_file.mimetype, None upload_file = session.scalar( select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) ) @@ -337,7 +685,7 @@ class AgentDriveService: raise AgentDriveError("source_not_found", "source file ref is invalid", status_code=404) from exc if upload_file is None: raise AgentDriveError("source_not_found", "source UploadFile not found for this tenant", status_code=404) - return upload_file.size, upload_file.mime_type + return upload_file.size, upload_file.mime_type, upload_file.hash def _cleanup_value( self, @@ -509,6 +857,8 @@ __all__ = [ "AgentDriveService", "DriveCommitItem", "DriveFileRef", + "DriveSkillMetadata", + "decode_drive_mention_ref", "normalize_drive_key", "parse_agent_drive_ref", ] diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index 2e103dec153..94515309e79 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -1,4 +1,12 @@ +"""Tenant credit pool accounting. + +Credit deductions are guarded by a tenant-level Redis lock before the database +row lock is acquired. This keeps concurrent usage accounting for one tenant +from piling up database transactions while preserving cross-tenant concurrency. +""" + import logging +from collections.abc import Callable from sqlalchemy import select from sqlalchemy.orm import Session @@ -7,13 +15,44 @@ from configs import dify_config from core.db.session_factory import session_factory from core.errors.error import QuotaExceededError from extensions.ext_database import db +from extensions.ext_redis import redis_client from models import TenantCreditPool from models.enums import ProviderQuotaType logger = logging.getLogger(__name__) +CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS = 10 +CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS = 5 + class CreditPoolService: + @staticmethod + def _get_tenant_lock_key(tenant_id: str) -> str: + return f"credit_pool:tenant:{tenant_id}:deduct_lock" + + @classmethod + def _deduct_with_tenant_lock(cls, tenant_id: str, deduct: Callable[[], int]) -> int: + lock_key = cls._get_tenant_lock_key(tenant_id) + lock = redis_client.lock( + lock_key, + timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS, + blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS, + ) + lock_acquired = False + + try: + lock_acquired = lock.acquire(blocking=True) + if not lock_acquired: + raise QuotaExceededError("Failed to acquire credit pool lock") + + return deduct() + finally: + if lock_acquired: + try: + lock.release() + except Exception: + logger.warning("Failed to release credit pool lock, tenant_id=%s", tenant_id, exc_info=True) + @staticmethod def _get_locked_pool(session: Session, tenant_id: str, pool_type: str) -> TenantCreditPool | None: return session.scalar( @@ -76,7 +115,7 @@ class CreditPoolService: if credits_required <= 0: return 0 - try: + def deduct() -> int: with session_factory.get_session_maker().begin() as session: pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) if not pool: @@ -89,14 +128,16 @@ class CreditPoolService: raise QuotaExceededError("Insufficient credits remaining") pool.quota_used += credits_required + return credits_required + + try: + return cls._deduct_with_tenant_lock(tenant_id, deduct) except QuotaExceededError: raise except Exception: logger.exception("Failed to deduct credits for tenant %s", tenant_id) raise QuotaExceededError("Failed to deduct credits") - return credits_required - @classmethod def deduct_credits_capped( cls, @@ -108,7 +149,7 @@ class CreditPoolService: if credits_required <= 0: return 0 - try: + def deduct() -> int: with session_factory.get_session_maker().begin() as session: pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) if not pool: @@ -121,6 +162,9 @@ class CreditPoolService: pool.quota_used += deducted_credits return deducted_credits + + try: + return cls._deduct_with_tenant_lock(tenant_id, deduct) except QuotaExceededError: raise except Exception: diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 71deec752e5..7ded11a1658 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -182,6 +182,10 @@ class EnterpriseRequest(BaseRequest): inner_headers: dict[str, str] = {INNER_TENANT_ID_HEADER: tenant_id} if account_id: inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id + + if not cls.base_url.startswith("http") or not cls.base_url.startswith("https") or not cls.base_url: + raise ValueError("ENTERPRISE_RBAC_API_URL is required when RBAC_ENABLED=true") + url = f"{cls.rbac_base_url}{endpoint}" mounts = cls._build_mounts() diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 39a3a61a781..dd1a50157f7 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -312,15 +312,26 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ "plugin.manage", "plugin.debug", "credential.use", + "credential.create", "credential.manage", + "billing.view", + "billing.subscription.manage", + "billing.manage", + "app.acl.preview", "app_library.access", "app.create_and_management", "app.tag.manage", + "dataset.acl.preview", "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", + "dataset.api_key.manage", + "snippets.create_and_modify", + "snippets.management", "tool.manage", "mcp.manage", + "snippets.create_and_modify", + "snippets.management", ] _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ @@ -334,15 +345,24 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ "plugin.manage", "plugin.debug", "credential.use", + "credential.create", "credential.manage", + "billing.view", + "billing.subscription.manage", + "billing.manage", "app_library.access", "app.create_and_management", "app.tag.manage", "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", + "dataset.api_key.manage", + "snippets.create_and_modify", + "snippets.management", "tool.manage", "mcp.manage", + "snippets.create_and_modify", + "snippets.management", ] _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ @@ -356,7 +376,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", + "snippets.create_and_modify", "tool.manage", + "snippets.create_and_modify", ] _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ @@ -373,6 +395,7 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ ] _LEGACY_APP_OWNER_KEYS: list[str] = [ + "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -384,6 +407,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [ ] _LEGACY_APP_ADMIN_KEYS: list[str] = [ + "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -395,6 +419,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [ ] _LEGACY_APP_EDITOR_KEYS: list[str] = [ + "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", @@ -406,12 +431,14 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [ ] _LEGACY_APP_NORMAL_KEYS: list[str] = [ + "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.monitor", ] _LEGACY_DATASET_OWNER_KEYS: list[str] = [ + "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", @@ -427,6 +454,7 @@ _LEGACY_DATASET_OWNER_KEYS: list[str] = [ ] _LEGACY_DATASET_ADMIN_KEYS: list[str] = [ + "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", @@ -442,6 +470,7 @@ _LEGACY_DATASET_ADMIN_KEYS: list[str] = [ ] _LEGACY_DATASET_EDITOR_KEYS: list[str] = [ + "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", @@ -492,6 +521,19 @@ _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = { } +def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]: + permissions = _LEGACY_MY_PERMISSIONS.get(role, {}) + return list( + dict.fromkeys( + [ + *permissions.get("workspace", []), + *permissions.get("app", []), + *permissions.get("dataset", []), + ] + ) + ) + + def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse: if not account_id: return MyPermissionsResponse() @@ -1518,21 +1560,44 @@ class RBACService: ) return AccessMatrixItem.model_validate(data or {}) - # ------------------------------------------------------------------ - # Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles). - # ------------------------------------------------------------------ class MemberRoles: @staticmethod def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse: - data = _inner_call( - "GET", - f"{_INNER_PREFIX}/members/rbac-roles", - tenant_id=tenant_id, - account_id=account_id, - params={"account_id": member_account_id}, - ) - rst = MemberRolesResponse.model_validate(data or {}) - return rst + if dify_config.RBAC_ENABLED: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + ) + rst = MemberRolesResponse.model_validate(data or {}) + return rst + else: + with session_factory.create_session() as session: + role = session.scalar( + select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.account_id == member_account_id, + ) + ) + return MemberRolesResponse( + account_id=member_account_id, + roles=[ + RBACRole( + id="", + name=role, + description="", + is_builtin=True, + type="", + permission_keys=_legacy_role_permission_keys(role), + role_tag="owner" if role == "owner" else role, + tenant_id=tenant_id, + ) + ] + if role + else [], + ) @staticmethod def batch_get( diff --git a/api/services/operation_service.py b/api/services/operation_service.py index 903efd26ae7..6869cf23ea2 100644 --- a/api/services/operation_service.py +++ b/api/services/operation_service.py @@ -3,6 +3,8 @@ from typing import TypedDict import httpx +OPERATION_REQUEST_TIMEOUT = httpx.Timeout(10.0, connect=3.0) + class UtmInfo(TypedDict, total=False): """Expected shape of the utm_info dict passed to record_utm. @@ -26,7 +28,9 @@ class OperationService: headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} url = f"{cls.base_url}{endpoint}" - response = httpx.request(method, url, json=json, params=params, headers=headers) + response = httpx.request( + method, url, json=json, params=params, headers=headers, timeout=OPERATION_REQUEST_TIMEOUT + ) return response.json() diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index dad7dff292e..c693f6318bd 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -23,8 +23,11 @@ from core.app.entities.task_entities import ( WorkflowStartStreamResponse, ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext -from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_forms import ( + load_form_dispositions_by_form_id, +) from core.workflow.human_input_policy import ( + FormDisposition, HumanInputSurface, enrich_human_input_pause_reasons, resolve_human_input_pause_reason_inputs, @@ -359,7 +362,7 @@ def _build_human_input_required_events( expiration_times_by_form_id: dict[str, int] = {} display_in_ui_by_form_id: dict[str, bool] = {} - form_tokens_by_form_id: dict[str, str] = {} + dispositions_by_form_id: dict[str, FormDisposition] = {} if human_input_form_ids and session_maker is not None: stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where( HumanInputForm.id.in_(human_input_form_ids) @@ -372,7 +375,7 @@ def _build_human_input_required_events( except (TypeError, json.JSONDecodeError): definition_payload = {} display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) - form_tokens_by_form_id = load_form_tokens_by_form_id( + dispositions_by_form_id = load_form_dispositions_by_form_id( human_input_form_ids, session=session, surface=human_input_surface, @@ -393,6 +396,7 @@ def _build_human_input_required_events( reason.inputs, variable_pool=variable_pool, ) + disposition = dispositions_by_form_id.get(form_id) response = HumanInputRequiredResponse( task_id=task_id, @@ -405,7 +409,8 @@ def _build_human_input_required_events( inputs=resolved_inputs, actions=reason.actions, display_in_ui=display_in_ui_by_form_id.get(form_id, False), - form_token=form_tokens_by_form_id.get(form_id), + form_token=disposition.form_token if disposition else None, + approval_channels=list(disposition.approval_channels) if disposition else [], resolved_default_values=reason.resolved_default_values, expiration_time=expiration_time, ), @@ -493,11 +498,11 @@ def _build_pause_event( for form_id in [reason.get("form_id")] if isinstance(form_id, str) ] - form_tokens_by_form_id: dict[str, str] = {} + dispositions_by_form_id: dict[str, FormDisposition] = {} expiration_times_by_form_id: dict[str, int] = {} if human_input_form_ids and session_maker is not None: with session_maker() as session: - form_tokens_by_form_id = load_form_tokens_by_form_id( + dispositions_by_form_id = load_form_dispositions_by_form_id( human_input_form_ids, session=session, surface=human_input_surface, @@ -512,7 +517,7 @@ def _build_pause_event( # otherwise clients see schema drift after resume. reasons = enrich_human_input_pause_reasons( reasons, - form_tokens_by_form_id=form_tokens_by_form_id, + dispositions_by_form_id=dispositions_by_form_id, expiration_times_by_form_id=expiration_times_by_form_id, ) diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py index 9282c878f00..b3799018cae 100644 --- a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py +++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_channel.py @@ -20,7 +20,7 @@ from testcontainers.redis import RedisContainer from libs.broadcast_channel.channel import BroadcastChannel, Subscription, Topic from libs.broadcast_channel.exc import SubscriptionClosedError -from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel +from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel class TestRedisBroadcastChannelIntegration: diff --git a/api/tests/unit_tests/controllers/common/test_app_access.py b/api/tests/unit_tests/controllers/common/test_app_access.py new file mode 100644 index 00000000000..d070cc6e0fc --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_app_access.py @@ -0,0 +1,161 @@ +"""Unit tests for controllers.common.app_access RBAC app-id access filtering.""" + +from __future__ import annotations + +import pytest + +from controllers.common.app_access import ( + APP_LIST_PERMISSION_KEYS, + AppAccessFilter, + has_app_list_permission, + resolve_app_access_filter, +) +from services.app_service import AppListParams +from services.enterprise.rbac_service import ( + MyPermissionsResponse, + ResourcePermissionKeys, + ResourcePermissionSnapshot, + ResourceWhitelistResources, + WorkspacePermissionSnapshot, +) + +_RBAC_MODULE = "controllers.common.app_access.enterprise_rbac_service" + + +def _permissions( + *, + workspace_keys: list[str] | None = None, + app_default_keys: list[str] | None = None, + app_overrides: list[ResourcePermissionKeys] | None = None, +) -> MyPermissionsResponse: + return MyPermissionsResponse( + workspace=WorkspacePermissionSnapshot(permission_keys=workspace_keys or []), + app=ResourcePermissionSnapshot( + default_permission_keys=app_default_keys or [], + overrides=app_overrides or [], + ), + ) + + +class TestHasAppListPermission: + def test_matches_known_preview_keys(self): + for key in APP_LIST_PERMISSION_KEYS: + assert has_app_list_permission([key]) + + def test_rejects_unknown_keys(self): + assert not has_app_list_permission(["app.export", "app.delete"]) + assert not has_app_list_permission([]) + + +class TestAppAccessFilterIsAppAccessible: + def test_unrestricted_sees_everything(self): + flt = AppAccessFilter.unrestricted() + assert flt.is_app_accessible("app-1", maintainer="someone", account_id="acc-1") + + def test_whitelisted_app_is_visible(self): + flt = AppAccessFilter(accessible_app_ids={"app-1"}, can_manage_own_apps=False) + assert flt.is_app_accessible("app-1", maintainer=None, account_id="acc-1") + assert not flt.is_app_accessible("app-2", maintainer=None, account_id="acc-1") + + def test_own_app_visible_only_with_manage_permission(self): + own = AppAccessFilter(accessible_app_ids=set(), can_manage_own_apps=True) + assert own.is_app_accessible("app-1", maintainer="acc-1", account_id="acc-1") + assert not own.is_app_accessible("app-1", maintainer="acc-2", account_id="acc-1") + + no_manage = AppAccessFilter(accessible_app_ids=set(), can_manage_own_apps=False) + assert not no_manage.is_app_accessible("app-1", maintainer="acc-1", account_id="acc-1") + + +class TestAppAccessFilterApplyToParams: + def test_unrestricted_leaves_params_untouched(self): + params = AppListParams() + AppAccessFilter.unrestricted().apply_to_params(params) + assert params.accessible_app_ids is None + assert params.include_own_apps is False + assert params.is_created_by_me is None + + def test_whitelisted_ids_are_sorted_with_own_apps_flag(self): + params = AppListParams() + AppAccessFilter(accessible_app_ids={"b", "a"}, can_manage_own_apps=True).apply_to_params(params) + assert params.accessible_app_ids == ["a", "b"] + assert params.include_own_apps is True + + def test_empty_set_with_manage_falls_back_to_maintained_apps(self): + # Own-app fallback must use maintainer (include_own_apps), consistent + # with is_app_accessible — not created_by (is_created_by_me). + params = AppListParams() + AppAccessFilter(accessible_app_ids=set(), can_manage_own_apps=True).apply_to_params(params) + assert params.accessible_app_ids == [] + assert params.include_own_apps is True + assert params.is_created_by_me is None + + def test_empty_set_without_manage_sees_nothing(self): + params = AppListParams() + AppAccessFilter(accessible_app_ids=set(), can_manage_own_apps=False).apply_to_params(params) + assert params.accessible_app_ids == [] + assert params.include_own_apps is False + assert params.is_created_by_me is None + + +class TestResolveAppAccessFilter: + def _patch_whitelist(self, monkeypatch: pytest.MonkeyPatch, whitelist: ResourceWhitelistResources) -> None: + monkeypatch.setattr( + f"{_RBAC_MODULE}.RBACService.AppAccess.whitelist_resources", + lambda tenant_id, account_id: whitelist, + ) + + def test_default_preview_is_unrestricted(self, monkeypatch: pytest.MonkeyPatch): + self._patch_whitelist(monkeypatch, ResourceWhitelistResources(unrestricted=True)) + permissions = _permissions(app_default_keys=["app.preview"]) + + flt = resolve_app_access_filter("tenant-1", "acc-1", permissions=permissions) + + assert flt.accessible_app_ids is None + assert flt.can_manage_own_apps is False + + def test_default_preview_overrides_whitelist_restriction(self, monkeypatch: pytest.MonkeyPatch): + self._patch_whitelist(monkeypatch, ResourceWhitelistResources(unrestricted=False, resource_ids=["app-9"])) + permissions = _permissions( + workspace_keys=["app.full_access", "app.create_and_management"], + ) + + flt = resolve_app_access_filter("tenant-1", "acc-1", permissions=permissions) + + # Workspace-level preview grant defeats the whitelist restriction. + assert flt.accessible_app_ids is None + assert flt.can_manage_own_apps is True + + def test_override_apps_collected_without_default_preview(self, monkeypatch: pytest.MonkeyPatch): + self._patch_whitelist(monkeypatch, ResourceWhitelistResources(unrestricted=True)) + permissions = _permissions( + app_overrides=[ + ResourcePermissionKeys(resource_id="app-1", permission_keys=["app.preview"]), + ResourcePermissionKeys(resource_id="app-2", permission_keys=["app.export"]), + ], + ) + + flt = resolve_app_access_filter("tenant-1", "acc-1", permissions=permissions) + + assert flt.accessible_app_ids == {"app-1"} + + def test_whitelist_union_with_override_apps(self, monkeypatch: pytest.MonkeyPatch): + self._patch_whitelist(monkeypatch, ResourceWhitelistResources(unrestricted=False, resource_ids=["app-5"])) + permissions = _permissions( + app_overrides=[ResourcePermissionKeys(resource_id="app-1", permission_keys=["app.acl.preview"])], + ) + + flt = resolve_app_access_filter("tenant-1", "acc-1", permissions=permissions) + + assert flt.accessible_app_ids == {"app-1", "app-5"} + + def test_fetches_permissions_when_not_supplied(self, monkeypatch: pytest.MonkeyPatch): + self._patch_whitelist(monkeypatch, ResourceWhitelistResources(unrestricted=False, resource_ids=[])) + monkeypatch.setattr( + f"{_RBAC_MODULE}.RBACService.MyPermissions.get", + lambda tenant_id, account_id: _permissions(workspace_keys=["app.create_and_management"]), + ) + + flt = resolve_app_access_filter("tenant-1", "acc-1") + + assert flt.accessible_app_ids == set() + assert flt.can_manage_own_apps is True 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 8b77772a36d..02fd5da55ac 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 @@ -20,6 +20,10 @@ from controllers.console.agent.composer import ( WorkflowAgentComposerValidateApi, ) from controllers.console.agent.roster import ( + AgentApiAccessApi, + AgentApiKeyApi, + AgentApiKeyListApi, + AgentApiStatusApi, AgentAppApi, AgentAppCopyApi, AgentAppListApi, @@ -28,6 +32,7 @@ from controllers.console.agent.roster import ( AgentLogsApi, AgentLogSourcesApi, AgentRosterVersionDetailApi, + AgentRosterVersionRestoreApi, AgentRosterVersionsApi, AgentStatisticsSummaryApi, ) @@ -149,6 +154,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//sandbox/files", "/agent//skills/upload", "/agent//files", + "/agent//api-access", + "/agent//api-enable", + "/agent//api-keys", + "/agent//api-keys/", "/agent//chat-messages", "/agent//chat-messages//stop", "/agent//feedbacks", @@ -158,6 +167,9 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//logs//messages", "/agent//log-sources", "/agent//statistics/summary", + "/agent//versions", + "/agent//versions/", + "/agent//versions//restore", "/agent/invite-options", ): assert route in paths @@ -173,6 +185,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/apps//agent-features", "/apps//agent-referencing-workflows", "/apps//agent-sandbox/files", + "/apps//api-access", ): assert route not in paths @@ -215,16 +228,34 @@ def test_agent_app_list_and_create_use_agent_route( roster_controller.AgentRosterService, "load_app_backing_agents_by_app_id", lambda _self, **kwargs: { - "app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None) + "app-list": SimpleNamespace( + id="agent-list", + role="List role", + debug_conversation_id="debug-conversation-list", + active_config_snapshot_id=None, + ) }, ) monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", lambda _self, **kwargs: SimpleNamespace( - id="agent-created", role="Created role", active_config_snapshot_id=None + id="agent-created", + role="Created role", + debug_conversation_id="debug-conversation-created", + active_config_snapshot_id=None, ), ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) monkeypatch.setattr( roster_controller.AgentRosterService, "load_published_references_by_agent_id", @@ -245,6 +276,16 @@ def test_agent_app_list_and_create_use_agent_route( ] }, ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_or_create_agent_app_debug_conversation_ids_by_agent_id", + lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-created", + ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -259,6 +300,7 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["total"] == 1 assert listed["data"][0]["id"] == "agent-list" assert listed["data"][0]["app_id"] == "app-list" + assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list" assert listed["data"][0]["role"] == "List role" assert listed["data"][0]["active_config_is_published"] is False assert listed["data"][0]["published_reference_count"] == 1 @@ -292,6 +334,7 @@ def test_agent_app_list_and_create_use_agent_route( assert status == 201 assert created["id"] == "agent-created" assert created["app_id"] == "app-created" + assert created["debug_conversation_id"] == "debug-conversation-created" assert created["role"] == "Created role" assert created["active_config_is_published"] is False assert "bound_agent_id" not in created @@ -332,7 +375,17 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None), + lambda _self, **kwargs: SimpleNamespace( + id=agent_id, + role="Resolved role", + debug_conversation_id="debug-conversation-detail", + active_config_snapshot_id=None, + ), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", ) monkeypatch.setattr( roster_controller.FeatureService, @@ -354,9 +407,10 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( monkeypatch.setattr(roster_controller, "AppService", FakeAppService) - detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id) + detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id) assert detail["id"] == agent_id assert detail["app_id"] == "app-1" + assert detail["debug_conversation_id"] == "debug-conversation-detail" assert detail["role"] == "Resolved role" assert detail["active_config_is_published"] is False assert "bound_agent_id" not in detail @@ -365,11 +419,12 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( "/console/api/agent/00000000-0000-0000-0000-000000000001", json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"}, ): - updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id) assert updated["name"] == "Renamed" assert updated["id"] == agent_id assert updated["app_id"] == "app-1" + assert updated["debug_conversation_id"] == "debug-conversation-detail" assert updated["role"] == "Resolved role" assert updated["active_config_is_published"] is False assert "bound_agent_id" not in updated @@ -399,7 +454,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( monkeypatch.setattr( roster_controller, "_serialize_agent_app_detail", - lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, + lambda app_model, **_kwargs: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, ) with app.test_request_context( @@ -428,6 +483,127 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( } +def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace( + id="app-1", + enable_api=True, + api_base_url="https://api.example.test/v1", + api_rpm=60, + api_rph=600, + ) + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 2) + + response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), "tenant-1", agent_id) + + assert response == { + "enabled": True, + "service_api_base_url": "https://api.example.test/v1", + "streaming_only": True, + "chat_endpoint": "https://api.example.test/v1/chat-messages", + "stop_endpoint": "https://api.example.test/v1/chat-messages/{task_id}/stop", + "conversations_endpoint": "https://api.example.test/v1/conversations", + "messages_endpoint": "https://api.example.test/v1/messages", + "files_upload_endpoint": "https://api.example.test/v1/files/upload", + "parameters_endpoint": "https://api.example.test/v1/parameters", + "info_endpoint": "https://api.example.test/v1/info", + "meta_endpoint": "https://api.example.test/v1/meta", + "api_rpm": 60, + "api_rph": 600, + "api_key_count": 2, + } + + +def test_agent_api_status_and_key_routes_resolve_backing_app( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + api_key_id = "00000000-0000-0000-0000-000000000002" + app_model = SimpleNamespace( + id="app-1", + enable_api=False, + api_base_url="https://api.example.test/v1", + api_rpm=0, + api_rph=0, + ) + captured: dict[str, object] = {} + + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 1) + + class FakeAppService: + def update_app_api_status(self, app_obj: object, enable_api: bool) -> object: + captured["enable"] = {"app": app_obj, "enable_api": enable_api} + app_model.enable_api = enable_api + return app_model + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + + def fake_get_api_key_list(self, resource_id: str, tenant_id: str): + captured["list_keys"] = {"resource_id": resource_id, "tenant_id": tenant_id} + return roster_controller.ApiKeyList(data=[]) + + def fake_create_api_key(self, resource_id: str, tenant_id: str): + captured["create_key"] = {"resource_id": resource_id, "tenant_id": tenant_id} + return SimpleNamespace( + id=api_key_id, + type="app", + token="app-test-token", + last_used_at=None, + created_at=None, + ) + + def fake_delete_api_key(self, resource_id: str, key_id: str, tenant_id: str, current_user: object) -> None: + captured["delete_key"] = { + "resource_id": resource_id, + "api_key_id": key_id, + "tenant_id": tenant_id, + "current_user": current_user, + } + + monkeypatch.setattr(AgentApiKeyListApi, "_get_api_key_list", fake_get_api_key_list) + monkeypatch.setattr(AgentApiKeyListApi, "_create_api_key", fake_create_api_key) + monkeypatch.setattr(AgentApiKeyApi, "_delete_api_key", fake_delete_api_key) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable", + json={"enable_api": True}, + ): + enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), "tenant-1", agent_id) + assert enabled["enabled"] is True + assert captured["enable"] == {"app": app_model, "enable_api": True} + + keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), "tenant-1", agent_id) + assert keys == {"data": []} + assert captured["list_keys"] == {"resource_id": "app-1", "tenant_id": "tenant-1"} + + created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), "tenant-1", agent_id) + assert status == 201 + assert created["id"] == api_key_id + assert created["token"] == "app-test-token" + assert captured["create_key"] == {"resource_id": "app-1", "tenant_id": "tenant-1"} + + current_user = SimpleNamespace(id="account-1", is_admin_or_owner=True) + deleted, delete_status = unwrap(AgentApiKeyApi.delete)( + AgentApiKeyApi(), + "tenant-1", + current_user, + agent_id, + api_key_id, + ) + assert (deleted, delete_status) == ("", 204) + assert captured["delete_key"] == { + "resource_id": "app-1", + "api_key_id": api_key_id, + "tenant_id": "tenant-1", + "current_user": current_user, + } + + def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) @@ -441,7 +617,12 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="", active_config_snapshot_id=None), + lambda _self, **kwargs: SimpleNamespace( + id=agent_id, + role="", + debug_conversation_id="debug-conversation-detail", + active_config_snapshot_id=None, + ), ) monkeypatch.setattr( roster_controller.FeatureService, @@ -464,7 +645,7 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"}, ): with pytest.raises(ValueError, match="String should have at least 1 character"): - unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id) def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -513,6 +694,13 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc ], }, ) + captured_restore: dict[str, object] = {} + + def restore_agent_version(_self, **kwargs): + captured_restore.update(kwargs) + return {"result": "success", "active_config_snapshot_id": kwargs["version_id"]} + + monkeypatch.setattr(roster_controller.AgentRosterService, "restore_agent_version", restore_agent_version) assert ( unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"] @@ -523,6 +711,16 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc ) assert version_detail["id"] == version_id assert version_detail["agent_id"] == agent_id + restored = unwrap(AgentRosterVersionRestoreApi.post)( + AgentRosterVersionRestoreApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id, version_id + ) + assert restored == {"result": "success", "active_config_snapshot_id": version_id} + assert captured_restore == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "version_id": version_id, + "account_id": "account-1", + } def test_agent_observability_routes_resolve_app_from_agent_id( @@ -870,7 +1068,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: agent_id = "00000000-0000-0000-0000-000000000001" - app_model = SimpleNamespace(id="app-1", mode="agent") + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") captured: dict[str, object] = {} def resolve_agent_app_model(**kwargs: object) -> object: @@ -909,7 +1107,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( def test_agent_chat_helper_forces_agent_streaming_and_external_trace( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: - app_model = SimpleNamespace(id="app-1", mode="agent") + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") current_user = SimpleNamespace(id=account_id) captured: dict[str, object] = {} @@ -918,6 +1116,11 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( return {"answer": "ok"} monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + lambda **kwargs: "debug-conversation-1", + ) monkeypatch.setattr( completion_controller.helper, "compact_generate_response", @@ -936,10 +1139,83 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert captured["streaming"] is True args = cast(dict[str, object], captured["args"]) assert args["response_mode"] == "streaming" + assert args["conversation_id"] == "debug-conversation-1" assert args["auto_generate_name"] is False assert args["external_trace_id"] == "trace-1" +def test_agent_chat_helper_rejects_foreign_debug_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, +) -> None: + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") + + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + lambda **kwargs: "owned-conversation", + ) + + with app.test_request_context( + json={ + "inputs": {}, + "query": "hello", + "response_mode": "streaming", + "conversation_id": "00000000-0000-0000-0000-000000000001", + } + ): + with pytest.raises(NotFound): + completion_controller._create_chat_message( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id=account_id), + app_model=app_model, + agent_id="agent-1", + ) + + +def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + class FakeRosterService: + def __init__(self, session: object) -> None: + calls.append({"session": session}) + + def get_or_create_agent_app_debug_conversation_id(self, **kwargs: object) -> str: + calls.append({"get_or_create": kwargs}) + return f"debug-{kwargs['agent_id']}" + + def get_app_backing_agent(self, **kwargs: object) -> object: + calls.append({"get_app_backing_agent": kwargs}) + return SimpleNamespace(id="backing-agent") + + monkeypatch.setattr(completion_controller, "AgentRosterService", FakeRosterService) + monkeypatch.setattr(completion_controller, "db", SimpleNamespace(session="session-1")) + + explicit_id = completion_controller._resolve_current_user_agent_debug_conversation_id( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + agent_id="agent-1", + ) + fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + agent_id=None, + ) + + 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[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"} + } + + @pytest.mark.parametrize( ("error", "expected"), [ @@ -986,7 +1262,7 @@ def test_agent_chat_helper_maps_generation_errors( def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" message_id = "00000000-0000-0000-0000-000000000002" - app_model = SimpleNamespace(id="app-1") + app_model = SimpleNamespace(id="app-1", mode="agent") current_user = SimpleNamespace(id="account-1") captured: dict[str, object] = {} @@ -1016,7 +1292,9 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions) monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail) - assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []} + assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", current_user, agent_id) == { + "data": [] + } assert cast(dict[str, object], captured["list"])["app_model"] is app_model with app.test_request_context(json={"message_id": message_id, "rating": "like"}): @@ -1073,11 +1351,73 @@ def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch "/console/api/agent/agent-1/chat-messages" f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1" ): - result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1")) + result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1", mode="chat")) assert result == {"data": [older_message_id], "limit": 1, "has_more": True} +def test_list_agent_chat_messages_uses_current_user_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + message_id = "00000000-0000-0000-0000-000000000011" + conversation = SimpleNamespace(id=conversation_id) + message = SimpleNamespace(id=message_id, created_at=1) + current_user = SimpleNamespace(id="account-1") + app_model = SimpleNamespace(id="app-1", mode="agent") + captured: dict[str, object] = {} + session = SimpleNamespace( + scalar=lambda _stmt: False, + scalars=lambda _stmt: SimpleNamespace(all=lambda: [message]), + ) + + class FakeMessagePaginationResponse: + @classmethod + def model_validate(cls, pagination: object, from_attributes: bool = False) -> object: + return SimpleNamespace( + model_dump=lambda mode: { + "data": [item.id for item in pagination.data], + "limit": pagination.limit, + "has_more": pagination.has_more, + } + ) + + def get_conversation(**kwargs: object) -> object: + captured.update(kwargs) + return conversation + + monkeypatch.setattr(message_controller.ConversationService, "get_conversation", get_conversation) + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None) + monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse) + + with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"): + result = message_controller._list_chat_messages(app_model=app_model, current_user=current_user) + + assert result == {"data": [message_id], "limit": 20, "has_more": False} + assert captured == {"app_model": app_model, "conversation_id": conversation_id, "user": current_user} + + +def test_list_agent_chat_messages_rejects_foreign_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + monkeypatch.setattr( + message_controller.ConversationService, + "get_conversation", + lambda **kwargs: (_ for _ in ()).throw(message_controller.ConversationNotExistsError()), + ) + + with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"): + with pytest.raises(NotFound): + message_controller._list_chat_messages( + app_model=SimpleNamespace(id="app-1", mode="agent"), + current_user=SimpleNamespace(id="account-1"), + ) + + def test_update_message_feedback_rejects_empty_rating_without_existing_feedback( app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py index 9d1b6c4c0e9..81f6fcf36bf 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py @@ -20,6 +20,10 @@ from controllers.console.app.agent_drive_inspector import ( AgentDriveListByAgentApi, AgentDrivePreviewApi, AgentDrivePreviewByAgentApi, + AgentDriveSkillInspectApi, + AgentDriveSkillInspectByAgentApi, + AgentDriveSkillListApi, + AgentDriveSkillListByAgentApi, ) from services.agent_drive_service import AgentDriveError @@ -97,6 +101,124 @@ def test_list_resolves_workflow_node_binding_agent(): assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1" +def test_skill_list_by_agent_calls_service(): + raw = _raw(AgentDriveSkillListByAgentApi.get) + with app.test_request_context("/"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.list_skills.return_value = [ + { + "path": "pdf-toolkit", + "skill_md_key": "pdf-toolkit/SKILL.md", + "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", + "name": "PDF Toolkit", + "description": "Work with PDFs.", + "size": 5, + "mime_type": "text/markdown", + "hash": None, + "created_at": 1718000000, + } + ] + body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1") + + assert body["items"][0]["path"] == "pdf-toolkit" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1" + + +def test_skill_list_resolves_workflow_node_binding_agent(): + raw = _raw(AgentDriveSkillListApi.get) + with app.test_request_context("/?node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + drive.return_value.list_skills.return_value = [] + body = raw(AgentDriveSkillListApi(), _APP) + + assert body == {"items": []} + assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9" + + +def test_skill_inspect_by_agent_returns_strict_json_response(): + raw = _raw(AgentDriveSkillInspectByAgentApi.get) + payload = { + "path": "pdf-toolkit", + "skill_md_key": "pdf-toolkit/SKILL.md", + "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", + "name": "PDF Toolkit", + "description": "Work with PDFs.", + "size": 5, + "mime_type": "text/markdown", + "hash": None, + "created_at": 1718000000, + "source": "skill_md", + "files": [ + { + "path": "SKILL.md", + "name": "SKILL.md", + "type": "file", + "drive_key": "pdf-toolkit/SKILL.md", + "available_in_drive": True, + } + ], + "file_tree": [], + "skill_md": { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# PDF Toolkit\nUse it.\n", + }, + "warnings": [], + } + with app.test_request_context("/"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP), + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.inspect_skill.return_value = payload + response = raw(AgentDriveSkillInspectByAgentApi(), "tenant-1", "agent-1", "pdf-toolkit") + + assert response.status_code == 200 + assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n" + assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data() + + +def test_skill_inspect_resolves_workflow_node_binding_agent(): + raw = _raw(AgentDriveSkillInspectApi.get) + payload = { + "path": "pdf-toolkit", + "skill_md_key": "pdf-toolkit/SKILL.md", + "archive_key": None, + "name": "PDF Toolkit", + "description": "", + "size": 5, + "mime_type": "text/markdown", + "hash": None, + "created_at": None, + "source": "skill_md", + "files": [], + "file_tree": [], + "skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"}, + "warnings": [], + } + with app.test_request_context("/?node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + drive.return_value.inspect_skill.return_value = payload + response = raw(AgentDriveSkillInspectApi(), _APP, "pdf-toolkit") + + assert response.get_json()["path"] == "pdf-toolkit" + assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9" + + def test_list_400_when_no_agent_bound(): raw = _raw(AgentDriveListApi.get) app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py index 1ad9637b7bb..d78bc1fc6dd 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -185,7 +185,7 @@ class TestPaginationMapping: "name": "owner", "description": "", "is_builtin": True, - "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]), + "permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"])), "role_tag": "owner", }, { @@ -196,7 +196,7 @@ class TestPaginationMapping: "name": "admin", "description": "", "is_builtin": True, - "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]), + "permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"])), "role_tag": "", }, ] @@ -336,23 +336,6 @@ class TestResourceAccessScopeBindings: class TestPaginationForwarding: - def test_role_members_get_forwards_outer_pagination_params(self, app): - with ( - app.test_request_context("/workspaces/current/rbac/roles/role-1/members?page=2&limit=50&reverse=true"), - patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), - patch("controllers.console.workspace.rbac.svc.RBACService.Roles.members") as mock_members, - patch("controllers.console.workspace.rbac._dump", return_value={}), - ): - inspect.unwrap(rbac_mod.RBACRoleMembersApi.get)(rbac_mod.RBACRoleMembersApi(), "role-1") - - _, _, role_id = mock_members.call_args.args - _, kwargs = mock_members.call_args - assert role_id == "role-1" - options = kwargs["options"] - assert options.page_number == 2 - assert options.results_per_page == 50 - assert options.reverse is True - def test_access_policies_get_forwards_outer_pagination_params(self, app): with ( app.test_request_context( diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py index 6c4a38b5d29..11cd1aa1380 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -1,16 +1,21 @@ import uuid from controllers.openapi.auth.composition import account_pipeline, auth_router, external_sso_pipeline -from controllers.openapi.auth.data import RequestContext +from controllers.openapi.auth.data import RBACRequirement, RequestContext from controllers.openapi.auth.flow import When from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter from controllers.openapi.auth.verify import ( + check_acl, + check_private_app_permission, + check_rbac_permission, check_workspace_member, check_workspace_mismatch, check_workspace_role, ) -from libs.oauth_bearer import TokenType +from core.rbac import RBACPermission, RBACResourceScope +from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole +from services.enterprise.enterprise_service import WebAppAccessMode def test_account_pipeline_is_auth_pipeline(): @@ -29,8 +34,8 @@ def test_account_pipeline_prepare_has_six_entries(): assert len(account_pipeline._prepare) == 6 -def test_account_auth_list_has_seven_entries(): - assert len(account_pipeline._auth) == 7 +def test_account_auth_list_has_eight_entries(): + assert len(account_pipeline._auth) == 8 def test_external_sso_pipeline_prepare_has_four_entries(): @@ -132,3 +137,89 @@ def test_app_path_selects_workspace_mismatch_check(): def test_workspace_path_skips_workspace_mismatch_check(): steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=None) assert check_workspace_mismatch not in steps + + +def _selected_webapp_steps(*, scope, app_access_mode): + """Select auth steps for an EE, webapp-auth-enabled, app-scoped request. + + Patches the config-backed conditions (edition + webapp_auth) so the gating + reduces to PATH_HAS_APP_ID, LOADED_APP_IS_PRIVATE, and the request scope. + """ + from unittest.mock import MagicMock, patch + + from controllers.openapi.auth.data import AuthData, Edition + + ctx = RequestContext( + token_type=TokenType.OAUTH_ACCOUNT, + scope=scope, + path_params={"app_id": str(uuid.uuid4())}, + ) + data = AuthData( + token_type=TokenType.OAUTH_ACCOUNT, + token_hash="x", + scopes=frozenset({scope}) if scope is not None else frozenset(), + app_access_mode=app_access_mode, + ) + features = MagicMock() + features.webapp_auth.enabled = True + selected = [] + with ( + patch("controllers.openapi.auth.conditions.current_edition", return_value=Edition.EE), + patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=features), + ): + for step in account_pipeline._auth: + if isinstance(step, When): + if step.applies(ctx, data): + selected.append(step._step) + else: + selected.append(step) + return selected + + +def test_apps_run_scope_selects_webapp_checks(): + steps = _selected_webapp_steps(scope=Scope.APPS_RUN, app_access_mode=WebAppAccessMode.PRIVATE) + assert check_acl in steps + assert check_private_app_permission in steps + + +def test_management_scope_skips_webapp_checks_on_private_app(): + # Export DSL et al. carry an app_id but use a management scope; the webapp + # end-user ACL / private-app gate must not block workspace members. + steps = _selected_webapp_steps(scope=Scope.APPS_READ, app_access_mode=WebAppAccessMode.PRIVATE) + assert check_acl not in steps + assert check_private_app_permission not in steps + + +def _selected_auth_steps_with_rbac(rbac): + ctx = RequestContext( + token_type=TokenType.OAUTH_ACCOUNT, + scope=Scope.APPS_READ, + path_params={"app_id": str(uuid.uuid4())}, + rbac=rbac, + ) + selected = [] + for step in account_pipeline._auth: + if isinstance(step, When): + if step.applies(ctx, None): + selected.append(step._step) + else: + selected.append(step) + return selected + + +def test_account_pipeline_selects_rbac_step_when_required(): + rbac = RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_VIEW_LAYOUT) + assert check_rbac_permission in _selected_auth_steps_with_rbac(rbac) + + +def test_account_pipeline_skips_rbac_step_without_requirement(): + assert check_rbac_permission not in _selected_auth_steps_with_rbac(None) + + +def test_external_sso_pipeline_never_enforces_rbac(): + # RBAC is a console (account) concern; external SSO callers are scope-gated. + rbac_steps = [ + s._step for s in external_sso_pipeline._auth if isinstance(s, When) and s._step is check_rbac_permission + ] + assert rbac_steps == [] + assert check_rbac_permission not in external_sso_pipeline._auth diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py index 159e5b42169..fa882ca96c4 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -5,19 +5,22 @@ from controllers.openapi.auth.conditions import ( EDITION_EE, EDITION_SAAS, HAS_ALLOWED_ROLES, + HAS_RBAC, LOADED_APP_IS_PRIVATE, PATH_HAS_APP_ID, TOKEN_IS_OAUTH_ACCOUNT, TOKEN_IS_OAUTH_EXTERNAL_SSO, WEBAPP_AUTH_ENABLED, + WEBAPP_RUN_SCOPED, WORKSPACE_MEMBERSHIP_REQUIRED, Cond, config_cond, data_cond, request_cond, ) -from controllers.openapi.auth.data import AuthData, Edition, RequestContext -from libs.oauth_bearer import TokenType +from controllers.openapi.auth.data import AuthData, Edition, RBACRequirement, RequestContext +from core.rbac import RBACPermission, RBACResourceScope +from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode @@ -137,6 +140,34 @@ def test_webapp_auth_enabled(): assert WEBAPP_AUTH_ENABLED(_ctx()) is True +def test_webapp_run_scoped_true_for_apps_run(): + assert WEBAPP_RUN_SCOPED(_ctx(scope=Scope.APPS_RUN)) is True + + +def test_webapp_run_scoped_false_for_management_scope(): + assert WEBAPP_RUN_SCOPED(_ctx(scope=Scope.APPS_READ)) is False + + +def test_webapp_run_scoped_false_when_scope_none(): + assert WEBAPP_RUN_SCOPED(_ctx()) is False + + +def _rbac_req(): + return RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN) + + +def test_has_rbac_true(): + assert HAS_RBAC(_ctx(rbac=_rbac_req())) is True + + +def test_has_rbac_false(): + assert HAS_RBAC(_ctx(rbac=None)) is False + + +def test_has_rbac_default(): + assert HAS_RBAC(_ctx()) is False + + def test_loaded_app_is_private(): data_private = _data(app_access_mode=WebAppAccessMode.PRIVATE) data_public = _data(app_access_mode=WebAppAccessMode.PUBLIC) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py b/api/tests/unit_tests/controllers/openapi/auth/test_verify.py index 96de1a2eda3..012b9880a12 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_verify.py @@ -5,17 +5,19 @@ import pytest from flask import Flask from werkzeug.exceptions import Forbidden, NotFound -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, RBACRequirement from controllers.openapi.auth.verify import ( check_acl, check_app_access, check_app_api_enabled, check_private_app_permission, + check_rbac_permission, check_scope, check_workspace_member, check_workspace_mismatch, check_workspace_role, ) +from core.rbac import RBACPermission, RBACResourceScope from libs.oauth_bearer import Scope, TokenType from models.account import Tenant, TenantAccountRole from models.model import App @@ -75,6 +77,67 @@ def test_check_app_access_raises_when_not_member(): check_app_access(data) +# --- check_rbac_permission --- + +_RBAC_REQ = RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_VIEW_LAYOUT) + + +def test_check_rbac_noop_when_no_requirement(): + with patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce: + check_rbac_permission(_data(rbac=None, caller_kind="account")) + mock_enforce.assert_not_called() + + +def test_check_rbac_noop_when_rbac_disabled(): + with ( + patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", False), + patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce, + ): + check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account")) + mock_enforce.assert_not_called() + + +def test_check_rbac_skips_end_user_caller(): + with ( + patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True), + patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce, + ): + check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="end_user")) + mock_enforce.assert_not_called() + + +def test_check_rbac_raises_when_context_missing(): + with patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True): + with pytest.raises(Forbidden, match="rbac context missing"): + check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account", account_id=None, tenant=None)) + + +def test_check_rbac_enforces_for_account_caller(): + tenant = MagicMock(spec=Tenant) + tenant.id = "t1" + account_id = uuid.uuid4() + data = _data( + rbac=_RBAC_REQ, + caller_kind="account", + account_id=account_id, + tenant=tenant, + path_params={"app_id": "app-1"}, + ) + with ( + patch("controllers.openapi.auth.verify.dify_config.RBAC_ENABLED", True), + patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce, + ): + check_rbac_permission(data) + mock_enforce.assert_called_once_with( + tenant_id="t1", + account_id=str(account_id), + resource_type=RBACResourceScope.APP, + scene=RBACPermission.APP_VIEW_LAYOUT, + resource_required=True, + path_args={"app_id": "app-1"}, + ) + + def test_check_acl_raises_when_app_or_mode_missing(): with pytest.raises(Forbidden): check_acl(_data(app=None, app_access_mode=None)) diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index d79b5bd642c..3a900011f54 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -20,6 +20,7 @@ def _stub_execute( edition=None, workspace_membership=False, allowed_roles=None, + rbac=None, ): """Bypass all auth logic; inject minimal AuthData and call the view directly.""" kwargs["auth_data"] = AuthData( @@ -30,6 +31,7 @@ def _stub_execute( scopes=frozenset({Scope.FULL}), required_scope=scope, allowed_roles=allowed_roles, + rbac=rbac, ) return view(*args, **kwargs) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py new file mode 100644 index 00000000000..708a0e59865 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py @@ -0,0 +1,73 @@ +from types import SimpleNamespace + +from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA +from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response +from controllers.service_api.app.error import AppUnavailableError + + +class _FakeApp(SimpleNamespace): + pass + + +def _app() -> _FakeApp: + from datetime import datetime + + return _FakeApp( + id="11111111-1111-1111-1111-111111111111", + name="Demo", + mode="chat", + description="d", + tags=[], + author_name="me", + updated_at=datetime(2026, 1, 1), + enable_api=True, + ) + + +def test_fields_none_returns_all_blocks(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), None) + assert resp.info is not None + assert resp.info.name == "Demo" + assert resp.parameters == {"k": "v"} + assert resp.input_schema == {"s": 1} + + +def test_fields_subset_limits_blocks(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), ["info"]) + assert resp.info is not None + assert resp.parameters is None + assert resp.input_schema is None + + +def test_info_omits_author_and_tags(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {}) + resp = build_app_describe_response(_app(), ["info"]) + assert resp.info is not None + # Usage-face describe must not expose creator identity or tags (cross-tenant leak). + assert not hasattr(resp.info, "author") + assert not hasattr(resp.info, "tags") + + +def test_parameters_fallback_on_app_unavailable(monkeypatch): + def _raise(app): + raise AppUnavailableError() + + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), ["parameters"]) + assert resp.parameters == dict(_EMPTY_PARAMETERS) + + +def test_input_schema_fallback_on_app_unavailable(monkeypatch): + def _raise(app): + raise AppUnavailableError() + + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise) + resp = build_app_describe_response(_app(), ["input_schema"]) + assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py index 9d207b1930a..e0b15585323 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py @@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins: - workspace_id is required. - numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]). - mode validates against the AppMode enum. -- name and tag have length caps. +- name has a length cap. """ from __future__ import annotations @@ -24,7 +24,6 @@ def test_defaults(): assert q.limit == 20 assert q.mode is None assert q.name is None - assert q.tag is None def test_workspace_id_required(): @@ -80,12 +79,6 @@ def test_name_length_capped(): AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201}) -def test_tag_length_capped(): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100}) - with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101}) - - def test_all_fields_accept_valid_values(): """Pin the happy-path acceptance for every field in one place.""" q = AppListQuery.model_validate( @@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values(): "limit": 50, "mode": "workflow", "name": "search", - "tag": "prod", } ) assert q.workspace_id == "00000000-0000-0000-0000-000000000001" @@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values(): assert q.mode is not None assert q.mode.value == "workflow" assert q.name == "search" - assert q.tag == "prod" diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py index 45a577443b7..788a7215ed2 100644 --- a/api/tests/unit_tests/controllers/openapi/test_error_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -26,11 +26,13 @@ from controllers.openapi._errors import ( ErrorBody, ErrorDetail, FilenameNotExists, + HumanInputFormNotFound, MemberLicenseExceeded, MemberLimitExceeded, OpenApiError, OpenApiErrorCode, OpenApiErrorFormatter, + RecipientSurfaceMismatch, ) from controllers.service_api.app.error import ( AppUnavailableError, @@ -319,6 +321,8 @@ ERROR_MATRIX = [ (BlockedFileExtensionError(), 400, "file_extension_blocked"), (MemberLimitExceeded(), 403, "member_limit_exceeded"), (MemberLicenseExceeded(), 403, "member_license_exceeded"), + (HumanInputFormNotFound(), 404, "form_not_found"), + (RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"), ] diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index f8d296deb3e..5659cd6eeff 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -11,8 +11,9 @@ from unittest.mock import Mock import pytest from flask import Flask -from werkzeug.exceptions import NotFound, UnprocessableEntity +from werkzeug.exceptions import UnprocessableEntity +from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch from controllers.openapi.auth.data import AuthData from libs.oauth_bearer import Scope, TokenType from models.human_input import RecipientType @@ -89,7 +90,7 @@ class TestOpenApiHumanInputFormGet: caller = SimpleNamespace(id="acct-1") with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"): - with pytest.raises(NotFound): + with pytest.raises(HumanInputFormNotFound): api.get.__wrapped__( api, app_id="app-1", @@ -101,7 +102,10 @@ class TestOpenApiHumanInputFormGet: from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi form = SimpleNamespace( - app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC) + app_id="other-app", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), ) service_mock = Mock() service_mock.get_form_by_token.return_value = form @@ -114,7 +118,7 @@ class TestOpenApiHumanInputFormGet: caller = SimpleNamespace(id="acct-1") with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): - with pytest.raises(NotFound): + with pytest.raises(HumanInputFormNotFound): api.get.__wrapped__( api, app_id="app-1", @@ -142,7 +146,7 @@ class TestOpenApiHumanInputFormGet: caller = SimpleNamespace(id="acct-1") with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): - with pytest.raises(NotFound): + with pytest.raises(RecipientSurfaceMismatch): api.get.__wrapped__( api, app_id="app-1", @@ -234,6 +238,38 @@ class TestOpenApiHumanInputFormPost: ) assert result == ({}, 200) + def test_post_standalone_web_app_recipient_submits( + self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + ): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + caller = SimpleNamespace(id="anyone") + + with app.test_request_context( + "/openapi/v1/apps/app-1/form/human_input/tok-1", + method="POST", + json={"action": "approve", "inputs": {}}, + ): + result = api.post.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + auth_data=_make_auth_data(app_model, caller, "end_user"), + ) + + service_mock.submit_form_by_token.assert_called_once() + assert result == ({}, 200) + def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline): """Malformed body → 422 via @accepts (was an unmapped pydantic error → 500).""" from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py index 930647608fa..7b84911d788 100644 --- a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py +++ b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py @@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics(): def test_app_info_response_dump_matches_spec(): - from controllers.openapi._models import AppInfoResponse + from controllers.openapi._models import AppInfo - obj = AppInfoResponse( + obj = AppInfo( id="app1", name="X", description="d", mode="chat", - author="alice", - tags=[{"name": "prod"}], ) assert obj.model_dump(mode="json") == { "id": "app1", "name": "X", "description": "d", "mode": "chat", - "author": "alice", - "tags": [{"name": "prod"}], } @@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters(): name="X", mode="chat", description=None, - tags=[], - author=None, updated_at="2026-05-05T00:00:00+00:00", service_api_enabled=True, ) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index 437bed9be48..9bc020b8b8f 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -136,6 +136,55 @@ class TestAppParameterApi: assert "user_input_form" in response assert "opening_statement" in response + @patch("controllers.service_api.wraps.user_logged_in") + @patch("controllers.service_api.wraps.current_app") + @patch("controllers.service_api.wraps.validate_and_get_api_token") + @patch("controllers.service_api.wraps.db") + @patch("controllers.service_api.app.app._get_agent_app_feature_dict_and_user_input_form") + def test_get_parameters_for_agent_app( + self, + mock_get_agent_parameters, + mock_db, + mock_validate_token, + mock_current_app, + mock_user_logged_in, + app: Flask, + mock_app_model, + ): + """Test retrieving parameters for an Agent App from Agent Soul app variables.""" + _configure_current_app_mock(mock_current_app) + + mock_app_model.mode = AppMode.AGENT + mock_app_model.app_model_config = None + mock_app_model.workflow = None + mock_get_agent_parameters.return_value = ( + {"opening_statement": "Hi from Agent"}, + [{"text-input": {"label": "topic", "variable": "topic", "required": True}}], + ) + + mock_api_token = Mock() + mock_api_token.app_id = mock_app_model.id + mock_api_token.tenant_id = mock_app_model.tenant_id + mock_validate_token.return_value = mock_api_token + + mock_tenant = Mock() + mock_tenant.status = TenantStatus.NORMAL + mock_db.session.get.side_effect = [mock_app_model, mock_tenant] + + mock_account = Mock() + mock_account.current_tenant = mock_tenant + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) + + with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): + api = AppParameterApi() + response = api.get() + + assert response["opening_statement"] == "Hi from Agent" + assert response["user_input_form"] == [ + {"text-input": {"label": "topic", "variable": "topic", "required": True}} + ] + mock_get_agent_parameters.assert_called_once_with(mock_app_model) + @patch("controllers.service_api.wraps.user_logged_in") @patch("controllers.service_api.wraps.current_app") @patch("controllers.service_api.wraps.validate_and_get_api_token") 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 8686f49a4a8..576b95110a0 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 @@ -29,7 +29,7 @@ from core.app.entities.task_entities import ( WorkflowPauseStreamResponse, ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper -from core.workflow.human_input_policy import HumanInputSurface +from core.workflow.human_input_policy import FormDisposition, HumanInputSurface from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType @@ -592,8 +592,10 @@ class TestHitlServiceApi: monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) monkeypatch.setattr( workflow_response_converter, - "load_form_tokens_by_form_id", - lambda form_ids, session=None, surface=None: {"form-1": "token"}, + "load_form_dispositions_by_form_id", + lambda form_ids, session=None, surface=None: { + "form-1": FormDisposition(form_token="token", approval_channels=[]) + }, ) reason = HumanInputRequired( @@ -652,8 +654,10 @@ class TestHitlServiceApi: snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) resumption_context = _build_resumption_context("task-ctx") monkeypatch.setattr( - "services.workflow_event_snapshot_service.load_form_tokens_by_form_id", - lambda form_ids, session=None, surface=None: {"form-1": "wtok"}, + "services.workflow_event_snapshot_service.load_form_dispositions_by_form_id", + lambda form_ids, session=None, surface=None: { + "form-1": FormDisposition(form_token="wtok", approval_channels=[]) + }, ) class _SessionContext: 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 ac9eddb680f..1970e5c1522 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 @@ -25,6 +25,15 @@ MINIMAL_GRAPH = { } +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) + + class TestAdvancedChatAppRunnerConversationVariables: """Test that AdvancedChatAppRunner correctly handles conversation variables.""" @@ -135,10 +144,8 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( - patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, - patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, + _patch_create_session(mock_session), patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, patch.object(runner, "_init_graph") as mock_init_graph, patch.object( runner, @@ -151,12 +158,6 @@ class TestAdvancedChatAppRunnerConversationVariables: 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, ): - # Setup mocks - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) - mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.engine = MagicMock() - # Mock GraphRuntimeState to accept the variable pool mock_graph_runtime_state_class.return_value = MagicMock() @@ -281,10 +282,8 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( - patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, - patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, + _patch_create_session(mock_session), patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, patch.object(runner, "_init_graph") as mock_init_graph, patch.object( runner, @@ -298,12 +297,6 @@ class TestAdvancedChatAppRunnerConversationVariables: 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, ): - # Setup mocks - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) - mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.engine = MagicMock() - # Mock ConversationVariable.from_variable to return mock objects mock_conv_vars = [] for var in workflow_vars: @@ -434,10 +427,8 @@ class TestAdvancedChatAppRunnerConversationVariables: # Patch the necessary components with ( - patch("core.app.apps.advanced_chat.app_runner.sessionmaker") as mock_sessionmaker, - patch("core.app.apps.advanced_chat.app_runner.Session") as mock_session_class, + _patch_create_session(mock_session), patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch("core.app.apps.advanced_chat.app_runner.db") as mock_db, patch.object(runner, "_init_graph") as mock_init_graph, patch.object( runner, @@ -450,12 +441,6 @@ class TestAdvancedChatAppRunnerConversationVariables: 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, ): - # Setup mocks - mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) - mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.engine = MagicMock() - # Mock GraphRuntimeState to accept the variable pool mock_graph_runtime_state_class.return_value = MagicMock() diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index 2076e42e9f9..2e3f7645c73 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -3,6 +3,7 @@ from uuid import uuid4 import pytest +import core.app.apps.advanced_chat.app_runner as module from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.app.entities.queue_entities import QueueStopEvent @@ -85,27 +86,24 @@ def build_runner(): def _patch_common_run_deps(runner: AdvancedChatAppRunner): """Context manager that patches common heavy deps used by run().""" + # create_session() returns a context manager whose body yields a session that + # supports both scalar() (app record lookup) and begin()/scalars().all() + # (conversation variable initialization). + mock_session = MagicMock() + mock_session.scalar.return_value = MagicMock() + mock_session.scalars.return_value.all.return_value = [] + + 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.multiple( "core.app.apps.advanced_chat.app_runner", - Session=MagicMock( - return_value=MagicMock( - __enter__=lambda s: s, - __exit__=lambda *a, **k: False, - scalar=lambda *a, **k: MagicMock(), - ), - ), - sessionmaker=MagicMock( - return_value=MagicMock( - begin=MagicMock( - return_value=MagicMock( - __enter__=lambda s: MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: []))), - __exit__=lambda *a, **k: False, - ), - ), - ), - ), + create_session=MagicMock(return_value=session_context), select=MagicMock(), - db=MagicMock(engine=MagicMock()), + session_factory=MagicMock(get_session_maker=MagicMock(return_value=MagicMock())), RedisChannel=MagicMock(), redis_client=MagicMock(), WorkflowEntry=MagicMock(**{"return_value.run.return_value": iter([])}), @@ -192,3 +190,42 @@ def test_run_returns_early_when_direct_output_via_handle_input_moderation(build_ # Ensure no further steps executed mock_anno.assert_not_called() mock_init_graph.assert_not_called() + + +def test_run_closes_scoped_session_before_workflow_run(build_runner): + runner = build_runner + events = [] + + mock_session = MagicMock() + mock_session.scalar.return_value = MagicMock() + session_context = MagicMock() + session_context.__enter__.return_value = mock_session + session_context.__exit__.return_value = False + + workflow_entry = MagicMock() + + def run_workflow(): + events.append("run") + return iter([]) + + workflow_entry.run.side_effect = run_workflow + + with ( + patch.object(module, "create_session", return_value=session_context), + patch.object(module, "session_factory", MagicMock(get_session_maker=MagicMock(return_value=MagicMock()))), + patch.object(module, "RedisChannel"), + patch.object(module, "redis_client"), + patch.object(module, "WorkflowEntry", return_value=workflow_entry), + patch.object(module.db.session, "close", side_effect=lambda: events.append("close")), + patch.object( + runner, + "handle_input_moderation", + return_value=(False, runner.application_generate_entity.inputs, runner.application_generate_entity.query), + ), + patch.object(runner, "handle_annotation_reply", return_value=False), + patch.object(runner, "_initialize_conversation_variables", return_value=[]), + patch.object(runner, "_init_graph", return_value=MagicMock()), + ): + runner.run() + + assert events == ["close", "run"] diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index b75f6d44943..28f416ac27f 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -175,6 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline: "actions": [{"id": "approve", "title": "Approve", "button_style": "default"}], "display_in_ui": True, "form_token": "token-1", + "approval_channels": [], "resolved_default_values": {}, "expiration_time": 123, } diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py index 6edcaca5a64..c2a49d1b3a6 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py @@ -19,6 +19,10 @@ def _soul() -> AgentSoulConfig: "model_settings": {"temperature": 0.2}, }, "prompt": {"system_prompt": "You are Iris."}, + "app_variables": [ + {"name": "topic", "type": "string", "required": True}, + {"name": "count", "type": "number", "default": 3}, + ], } ) @@ -32,7 +36,10 @@ def test_model_and_prompt_come_from_soul(): "completion_params": {"temperature": 0.2}, } assert d["pre_prompt"] == "You are Iris." - assert d["user_input_form"] == [] + assert d["user_input_form"] == [ + {"text-input": {"label": "topic", "variable": "topic", "required": True}}, + {"number": {"label": "count", "variable": "count", "required": False, "default": 3}}, + ] def test_feature_flags_come_from_app_model_config_when_present(): diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py index d7988cbf74d..af2fb22ec78 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py @@ -13,12 +13,24 @@ def runner(): return AgentChatAppRunner() +def patch_create_session(mocker: MockerFixture, *, return_value=None, side_effect=None): + session = mocker.MagicMock() + if side_effect is not None: + session.scalar.side_effect = side_effect + else: + session.scalar.return_value = return_value + session_context = mocker.MagicMock() + session_context.__enter__.return_value = session + mocker.patch("core.app.apps.agent_chat.app_runner.create_session", return_value=session_context) + return session + + class TestAgentChatAppRunnerRun: def test_run_app_not_found(self, runner: AgentChatAppRunner, mocker: MockerFixture): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", agent=mocker.MagicMock()) generate_entity = mocker.MagicMock(app_config=app_config, inputs={}, query="q", files=[], stream=True) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=None) + patch_create_session(mocker, return_value=None) with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) @@ -37,7 +49,7 @@ class TestAgentChatAppRunnerRun: conversation_id=None, ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", side_effect=ModerationError("bad")) mocker.patch.object(runner, "direct_output") @@ -62,7 +74,7 @@ class TestAgentChatAppRunnerRun: invoke_from=mocker.MagicMock(), ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) annotation = mocker.MagicMock(id="anno", content="answer") @@ -91,7 +103,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -121,7 +133,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -163,7 +175,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -179,10 +191,7 @@ class TestAgentChatAppRunnerRun: conversation = mocker.MagicMock(id="conv") message = mocker.MagicMock(id="msg") - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, conversation, message], - ) + patch_create_session(mocker, side_effect=[app_record, conversation, message]) runner_cls = mocker.MagicMock() mocker.patch(f"core.app.apps.agent_chat.app_runner.{expected_runner}", runner_cls) @@ -219,7 +228,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -235,10 +244,7 @@ class TestAgentChatAppRunnerRun: conversation = mocker.MagicMock(id="conv") message = mocker.MagicMock(id="msg") - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, conversation, message], - ) + patch_create_session(mocker, side_effect=[app_record, conversation, message]) with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), conversation, message) @@ -267,7 +273,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -283,10 +289,7 @@ class TestAgentChatAppRunnerRun: conversation = mocker.MagicMock(id="conv") message = mocker.MagicMock(id="msg") - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, conversation, message], - ) + patch_create_session(mocker, side_effect=[app_record, conversation, message]) runner_cls = mocker.MagicMock() mocker.patch("core.app.apps.agent_chat.app_runner.FunctionCallAgentRunner", runner_cls) @@ -323,10 +326,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, None], - ) + patch_create_session(mocker, side_effect=[app_record, None]) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -357,10 +357,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, mocker.MagicMock(id="conv"), None], - ) + patch_create_session(mocker, side_effect=[app_record, mocker.MagicMock(id="conv"), None]) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -391,7 +388,7 @@ class TestAgentChatAppRunnerRun: user_id="user", ) - mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record) + patch_create_session(mocker, return_value=app_record) mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) @@ -407,10 +404,7 @@ class TestAgentChatAppRunnerRun: conversation = mocker.MagicMock(id="conv") message = mocker.MagicMock(id="msg") - mocker.patch( - "core.app.apps.agent_chat.app_runner.db.session.scalar", - side_effect=[app_record, conversation, message], - ) + patch_create_session(mocker, side_effect=[app_record, conversation, message]) with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), conversation, message) diff --git a/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py b/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py index 6f104a5eaa1..23334dbe67c 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py @@ -1,5 +1,6 @@ +from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import Mock, patch +from unittest.mock import ANY, MagicMock, Mock, patch import pytest @@ -29,6 +30,19 @@ class DummyQueueManager: self.published.append((event, pub_from)) +@contextmanager +def patched_create_session(*, return_value=None, side_effect=None): + session = MagicMock() + if side_effect is not None: + session.scalar.side_effect = side_effect + else: + session.scalar.return_value = return_value + session_context = MagicMock() + session_context.__enter__.return_value = session + with patch("core.app.apps.chat.app_runner.create_session", return_value=session_context): + yield session + + class TestChatAppGenerator: def test_generate_requires_query(self): generator = ChatAppGenerator() @@ -167,7 +181,7 @@ class TestChatAppRunner: invoke_from=InvokeFrom.SERVICE_API, ) - with patch("core.app.apps.chat.app_runner.db.session.scalar", return_value=None): + with patched_create_session(return_value=None): with pytest.raises(ValueError): runner.run(app_generate_entity, DummyQueueManager(), SimpleNamespace(), SimpleNamespace(id="m1")) @@ -195,10 +209,7 @@ class TestChatAppRunner: ) with ( - patch( - "core.app.apps.chat.app_runner.db.session.scalar", - return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - ), + patched_create_session(return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1")), patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), patch.object(ChatAppRunner, "moderation_for_inputs", side_effect=ModerationError("blocked")), patch.object(ChatAppRunner, "direct_output") as mock_direct, @@ -233,10 +244,7 @@ class TestChatAppRunner: annotation = SimpleNamespace(id="ann-1", content="answer") with ( - patch( - "core.app.apps.chat.app_runner.db.session.scalar", - return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - ), + patched_create_session(return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1")), patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=annotation), @@ -272,13 +280,73 @@ class TestChatAppRunner: ) with ( - patch( - "core.app.apps.chat.app_runner.db.session.scalar", - return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - ), + patched_create_session(return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1")), patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=None), patch.object(ChatAppRunner, "check_hosting_moderation", return_value=True), ): runner.run(app_generate_entity, DummyQueueManager(), SimpleNamespace(), SimpleNamespace(id="m1")) + + def test_run_closes_scoped_session_before_stream_consumption(self): + runner = ChatAppRunner() + app_config = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + prompt_template=None, + external_data_variables=[], + dataset=None, + additional_features=None, + ) + app_generate_entity = DummyGenerateEntity( + app_config=app_config, + model_conf=SimpleNamespace(provider_model_bundle=None, model="model-1", parameters={}), + inputs={}, + query="hi", + files=[], + file_upload_config=None, + conversation_id=None, + stream=True, + user_id="user-1", + invoke_from=InvokeFrom.SERVICE_API, + ) + + events = [] + queue_manager = DummyQueueManager() + model_instance = MagicMock() + + def invoke_stream(): + events.append("first-chunk") + yield "chunk" + + def invoke_llm(**kwargs): + events.append("invoke") + return invoke_stream() + + with ( + patched_create_session(return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1")), + patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), + patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), + patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=None), + patch.object(ChatAppRunner, "check_hosting_moderation", return_value=False), + patch.object(ChatAppRunner, "recalc_llm_max_tokens"), + patch.object( + ChatAppRunner, + "_handle_invoke_result", + side_effect=lambda invoke_result, **kwargs: list(invoke_result), + ) as mock_handle, + patch("core.app.apps.chat.app_runner.ModelInstance", return_value=model_instance), + patch("core.app.apps.chat.app_runner.db.session.close", side_effect=lambda: events.append("close")), + ): + model_instance.invoke_llm.side_effect = invoke_llm + runner.run(app_generate_entity, queue_manager, SimpleNamespace(), SimpleNamespace(id="m1")) + + assert events == ["close", "invoke", "first-chunk"] + mock_handle.assert_called_once_with( + invoke_result=ANY, + queue_manager=queue_manager, + stream=True, + message_id="m1", + user_id="user-1", + tenant_id="tenant-1", + ) diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index 8dcf6e91935..2fdb197852a 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -1,5 +1,6 @@ +from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import ANY, MagicMock, patch import pytest from pytest_mock import MockerFixture @@ -47,25 +48,28 @@ def _build_generate_entity(app_config, file_upload_config=None): ) +@contextmanager +def patched_create_session(*, return_value=None): + session = MagicMock() + session.scalar.return_value = return_value + session_context = MagicMock() + session_context.__enter__.return_value = session + with patch.object(module, "create_session", return_value=session_context): + yield session + + class TestCompletionAppRunner: def test_run_app_not_found(self, runner, mocker: MockerFixture): - session = mocker.MagicMock() - session.scalar.return_value = None - mocker.patch.object(module.db, "session", session) - app_config = _build_app_config() app_generate_entity = _build_generate_entity(app_config) - with pytest.raises(ValueError): - runner.run(app_generate_entity, MagicMock(), MagicMock()) + with patched_create_session(return_value=None): + with pytest.raises(ValueError): + runner.run(app_generate_entity, MagicMock(), MagicMock()) def test_run_moderation_error_outputs_direct(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") - session = mocker.MagicMock() - session.scalar.return_value = app_record - mocker.patch.object(module.db, "session", session) - app_config = _build_app_config() app_generate_entity = _build_generate_entity(app_config) @@ -74,7 +78,8 @@ class TestCompletionAppRunner: runner.direct_output = MagicMock() runner._handle_invoke_result = MagicMock() - runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) + with patched_create_session(return_value=app_record): + runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) runner.direct_output.assert_called_once() runner._handle_invoke_result.assert_not_called() @@ -82,10 +87,6 @@ class TestCompletionAppRunner: def test_run_hosting_moderation_stops(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") - session = mocker.MagicMock() - session.scalar.return_value = app_record - mocker.patch.object(module.db, "session", session) - app_config = _build_app_config() app_generate_entity = _build_generate_entity(app_config) @@ -94,18 +95,14 @@ class TestCompletionAppRunner: runner.check_hosting_moderation = MagicMock(return_value=True) runner._handle_invoke_result = MagicMock() - runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) + with patched_create_session(return_value=app_record): + runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) runner._handle_invoke_result.assert_not_called() def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") - session = mocker.MagicMock() - session.scalar.return_value = app_record - session.close = MagicMock() - mocker.patch.object(module.db, "session", session) - retrieve_config = MagicMock(query_variable="qvar") dataset_config = MagicMock(dataset_ids=["ds"], retrieve_config=retrieve_config) additional_features = MagicMock(show_retrieve_source=True) @@ -135,19 +132,56 @@ class TestCompletionAppRunner: model_instance.invoke_llm.return_value = "invoke_result" mocker.patch.object(module, "ModelInstance", return_value=model_instance) - runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg", tenant_id="tenant")) + with patched_create_session(return_value=app_record): + runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg", tenant_id="tenant")) dataset_retrieval.retrieve.assert_called_once() assert dataset_retrieval.retrieve.call_args.kwargs["query"] == "query_from_input" runner._handle_invoke_result.assert_called_once() + def test_run_closes_scoped_session_before_stream_consumption(self, runner, mocker: MockerFixture): + app_record = MagicMock(id="app1", tenant_id="tenant") + app_config = _build_app_config() + app_generate_entity = _build_generate_entity(app_config) + queue_manager = MagicMock() + + events = [] + runner.organize_prompt_messages = MagicMock(return_value=([], None)) + runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query")) + runner.check_hosting_moderation = MagicMock(return_value=False) + runner.recalc_llm_max_tokens = MagicMock() + runner._handle_invoke_result = MagicMock(side_effect=lambda invoke_result, **kwargs: list(invoke_result)) + + model_instance = MagicMock() + + def invoke_stream(): + events.append("first-chunk") + yield "chunk" + + def invoke_llm(**kwargs): + events.append("invoke") + return invoke_stream() + + model_instance.invoke_llm.side_effect = invoke_llm + mocker.patch.object(module, "ModelInstance", return_value=model_instance) + mocker.patch.object(module.db.session, "close", side_effect=lambda: events.append("close")) + + with patched_create_session(return_value=app_record): + runner.run(app_generate_entity, queue_manager, MagicMock(id="msg")) + + assert events == ["close", "invoke", "first-chunk"] + runner._handle_invoke_result.assert_called_once_with( + invoke_result=ANY, + queue_manager=queue_manager, + stream=True, + message_id="msg", + user_id="user", + tenant_id="tenant", + ) + def test_run_uses_low_image_detail_default(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") - session = mocker.MagicMock() - session.scalar.return_value = app_record - mocker.patch.object(module.db, "session", session) - app_config = _build_app_config() app_generate_entity = _build_generate_entity(app_config, file_upload_config=None) @@ -155,7 +189,8 @@ class TestCompletionAppRunner: runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query")) runner.check_hosting_moderation = MagicMock(return_value=True) - runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) + with patched_create_session(return_value=app_record): + runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg")) assert ( runner.organize_prompt_messages.call_args.kwargs["image_detail_config"] diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index 1eed76cf843..f24f799464f 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -53,6 +53,32 @@ def _build_app_generate_entity() -> SimpleNamespace: ) +def _patch_create_session(mocker: MockerFixture, session: MagicMock, *, events: list[str] | None = None): + """Patch create_session() to yield ``session`` inside its ``with`` body and ``begin()`` block. + + The runner now obtains short-lived sessions via ``create_session()`` instead of the + Flask scoped ``db.session``, so tests patch the module-level ``create_session`` and + hand back a context manager that yields the mock session. + """ + session_context = MagicMock() + + def enter_session(): + if events is not None: + events.append("session_enter") + return session + + def exit_session(*args): + if events is not None: + events.append("session_exit") + return False + + session_context.__enter__.side_effect = enter_session + session_context.__exit__.side_effect = exit_session + session.begin.return_value.__enter__.return_value = session + session.begin.return_value.__exit__.return_value = False + return mocker.patch.object(module, "create_session", return_value=session_context) + + @pytest.fixture def runner(): app_generate_entity = _build_app_generate_entity() @@ -77,13 +103,14 @@ def test_get_app_id(runner): assert runner._get_app_id() == "pipe" -def test_get_workflow_returns_workflow(mocker, runner): +def test_get_workflow_returns_workflow(runner): pipeline = MagicMock(tenant_id="tenant", id="pipe") workflow = MagicMock(id="wf") - mocker.patch.object(module.db, "session", MagicMock(scalar=MagicMock(return_value=workflow))) + session = MagicMock() + session.scalar.return_value = workflow - result = runner.get_workflow(pipeline=pipeline, workflow_id="wf") + result = runner.get_workflow(session=session, pipeline=pipeline, workflow_id="wf") assert result == workflow @@ -116,7 +143,7 @@ def test_update_document_status_on_failure(mocker, runner): session = MagicMock() session.scalar.return_value = document - mocker.patch.object(module.db, "session", session) + _patch_create_session(mocker, session) event = GraphRunFailedEvent(error="boom") @@ -124,7 +151,10 @@ def test_update_document_status_on_failure(mocker, runner): assert document.indexing_status == "error" assert document.error == "boom" - session.commit.assert_called_once() + session.add.assert_called_once_with(document) + session.begin.assert_called_once() + session.begin.return_value.__enter__.assert_called_once() + session.begin.return_value.__exit__.assert_called_once() def test_run_pipeline_not_found(mocker: MockerFixture): @@ -135,7 +165,7 @@ def test_run_pipeline_not_found(mocker: MockerFixture): session = MagicMock() session.get.side_effect = [None, None] - mocker.patch.object(module.db, "session", session) + _patch_create_session(mocker, session) runner = PipelineRunner( application_generate_entity=app_generate_entity, @@ -158,7 +188,7 @@ def test_run_workflow_not_initialized(mocker: MockerFixture): session = MagicMock() session.get.side_effect = [None, pipeline] - mocker.patch.object(module.db, "session", session) + _patch_create_session(mocker, session) runner = PipelineRunner( application_generate_entity=app_generate_entity, @@ -184,7 +214,7 @@ def test_run_single_iteration_path(mocker: MockerFixture): session = MagicMock() session.get.side_effect = [end_user, pipeline] - mocker.patch.object(module.db, "session", session) + _patch_create_session(mocker, session) runner = PipelineRunner( application_generate_entity=app_generate_entity, @@ -229,10 +259,11 @@ def test_run_normal_path_builds_graph(mocker: MockerFixture): pipeline = MagicMock(id="pipe") end_user = MagicMock(session_id="sess") + events = [] session = MagicMock() session.get.side_effect = [end_user, pipeline] - mocker.patch.object(module.db, "session", session) + _patch_create_session(mocker, session, events=events) workflow = MagicMock( id="wf", @@ -276,10 +307,11 @@ def test_run_normal_path_builds_graph(mocker: MockerFixture): workflow_entry = MagicMock() workflow_entry.graph_engine = MagicMock() - workflow_entry.run.return_value = [] + workflow_entry.run.side_effect = lambda: events.append("workflow_run") or [] mocker.patch.object(module, "WorkflowEntry", return_value=workflow_entry) mocker.patch.object(module, "WorkflowPersistenceLayer", return_value=MagicMock()) runner.run() + assert events == ["session_enter", "session_exit", "workflow_run"] runner._init_rag_pipeline_graph.assert_called_once() diff --git a/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py b/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py new file mode 100644 index 00000000000..a9aafd5949d --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py @@ -0,0 +1,59 @@ +from unittest.mock import patch + +import pytest + +from core.app.apps.base_app_queue_manager import PublishFrom +from core.app.apps.exc import GenerateTaskStoppedError +from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager +from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.queue_entities import QueueTextChunkEvent +from models.model import AppMode + + +def _message_queue_manager(app_mode: str) -> MessageBasedAppQueueManager: + with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis: + mock_redis.setex.return_value = True + return MessageBasedAppQueueManager( + task_id="task-1", + user_id="user-1", + invoke_from=InvokeFrom.DEBUGGER, + conversation_id="conversation-1", + app_mode=app_mode, + message_id="message-1", + ) + + +def _workflow_queue_manager(app_mode: str) -> WorkflowAppQueueManager: + with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis: + mock_redis.setex.return_value = True + return WorkflowAppQueueManager( + task_id="task-1", + user_id="user-1", + invoke_from=InvokeFrom.DEBUGGER, + app_mode=app_mode, + ) + + +def test_message_queue_does_not_raise_legacy_stop_for_advanced_chat() -> None: + manager = _message_queue_manager(AppMode.ADVANCED_CHAT.value) + + with patch.object(manager, "_is_stopped", return_value=True): + manager.publish(QueueTextChunkEvent(text="chunk"), PublishFrom.APPLICATION_MANAGER) + + +def test_workflow_queue_does_not_read_legacy_stop_flag() -> None: + manager = _workflow_queue_manager(AppMode.WORKFLOW.value) + + with patch.object(manager, "_is_stopped", return_value=True) as is_stopped: + manager.publish(QueueTextChunkEvent(text="chunk"), PublishFrom.APPLICATION_MANAGER) + + is_stopped.assert_not_called() + + +def test_message_queue_keeps_legacy_stop_for_non_graphengine_chat() -> None: + manager = _message_queue_manager(AppMode.CHAT.value) + + with patch.object(manager, "_is_stopped", return_value=True): + with pytest.raises(GenerateTaskStoppedError): + manager.publish(QueueTextChunkEvent(text="chunk"), PublishFrom.APPLICATION_MANAGER) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index 319e603b351..098a63a8f91 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -26,6 +26,26 @@ from models.account import Account from models.human_input import RecipientType +class _FakeSession: + """Stub session: `execute` feeds the form-expiration query, `scalars` the recipients.""" + + def __init__(self, *, execute_rows=(), scalars_rows=()): + self._execute_rows = execute_rows + self._scalars_rows = scalars_rows + + def execute(self, _stmt): + return list(self._execute_rows) + + def scalars(self, _stmt): + return list(self._scalars_rows) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class _RecordingWorkflowAppRunner(WorkflowAppRunner): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -97,11 +117,11 @@ def test_graph_run_paused_event_emits_queue_pause_event(): assert queue_event.paused_nodes == ["node-pause-1"] -def _build_converter(): +def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API): application_generate_entity = SimpleNamespace( inputs={}, files=[], - invoke_from=InvokeFrom.SERVICE_API, + invoke_from=invoke_from, app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"), ) system_variables = build_system_variables( @@ -131,32 +151,15 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon ) expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + session = _FakeSession( + execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')], + scalars_rows=[ + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"), + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"), + ], + ) - class _FakeSession: - def execute(self, _stmt): - return [("form-1", expiration_time, '{"display_in_ui": true}')] - - def scalars(self, _stmt): - return [ - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.CONSOLE, - access_token="console-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.BACKSTAGE, - access_token="backstage-token", - ), - ] - - 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, "Session", lambda **_: session) monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) reason = HumanInputRequired( @@ -195,10 +198,92 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon assert hi_resp.data.inputs[0].output_variable_name == "field" assert hi_resp.data.actions[0].id == "approve" assert hi_resp.data.display_in_ui is True - assert hi_resp.data.form_token == "backstage-token" + assert hi_resp.data.form_token is None + assert hi_resp.data.approval_channels == ["console"] assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) +def _build_paused_human_input_response(monkeypatch, recipients): + """Drive the live OPENAPI pause path with the given recipients via a fake session.""" + converter = _build_converter(invoke_from=InvokeFrom.OPENAPI) + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + session = _FakeSession( + execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')], + scalars_rows=list(recipients), + ) + + monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + + reason = HumanInputRequired( + form_id="form-1", + form_content="Rendered", + inputs=[ParagraphInputConfig(output_variable_name="field")], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="node-id", + node_title="Human Step", + ) + queue_event = QueueWorkflowPausedEvent( + reasons=[reason], + outputs={}, + paused_nodes=["node-id"], + ) + + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + responses = converter.workflow_pause_to_stream_response( + event=queue_event, + task_id="task", + graph_runtime_state=runtime_state, + ) + assert isinstance(responses[0], HumanInputRequiredResponse) + return responses + + +def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch): + responses = _build_paused_human_input_response( + monkeypatch, + recipients=[ + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"), + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"), + ], + ) + + hi_resp = responses[0] + assert hi_resp.data.form_token is None + assert hi_resp.data.approval_channels == ["console", "email"] + + pause_resp = responses[-1] + assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"] + + +def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch): + responses = _build_paused_human_input_response( + monkeypatch, + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-app-token", + ), + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"), + ], + ) + + hi_resp = responses[0] + assert hi_resp.data.form_token == "web-app-token" + assert hi_resp.data.approval_channels == ["console"] + + pause_resp = responses[-1] + assert pause_resp.data.reasons[0]["approval_channels"] == ["console"] + + def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch): converter = _build_converter() converter.workflow_start_to_stream_response( @@ -209,21 +294,9 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc ) expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')]) - class _FakeSession: - def execute(self, _stmt): - return [("form-1", expiration_time, '{"display_in_ui": true}')] - - def scalars(self, _stmt): - return [] - - 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, "Session", lambda **_: session) monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) reason = HumanInputRequired( diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py index 6133be98676..e3b86530098 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py @@ -1,9 +1,8 @@ from __future__ import annotations -import pytest +from unittest.mock import patch from core.app.apps.base_app_queue_manager import PublishFrom -from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueMessageEndEvent, QueuePingEvent @@ -17,11 +16,16 @@ class TestWorkflowAppQueueManager: invoke_from=InvokeFrom.DEBUGGER, app_mode="workflow", ) - manager._is_stopped = lambda: True - with pytest.raises(GenerateTaskStoppedError): + with ( + patch.object(manager, "_is_stopped", return_value=True) as is_stopped, + patch.object(manager, "stop_listen") as stop_listen, + ): manager._publish(QueueMessageEndEvent(llm_result=None), PublishFrom.APPLICATION_MANAGER) + stop_listen.assert_called_once() + is_stopped.assert_not_called() + def test_publish_non_stop_event_does_not_raise(self): manager = WorkflowAppQueueManager( task_id="task", diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index ea21a1cc1a6..0aaee900e37 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -134,6 +134,7 @@ class TestWorkflowGenerateTaskPipeline: "actions": [], "display_in_ui": False, "form_token": None, + "approval_channels": [], "resolved_default_values": {}, "expiration_time": 1, } diff --git a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py index 2ed7c70ed94..e95ca8a7bf8 100644 --- a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py +++ b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest from pydantic import BaseModel from pytest_mock import MockerFixture +from sqlalchemy.dialects import postgresql from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation @@ -16,6 +17,16 @@ class _Chunk(BaseModel): value: int +def _build_app_model_config(result: dict | None = None): + app_model_config = MagicMock() + app_model_config.app_id = "app-1" + app_model_config.to_dict.return_value = result or { + "user_input_form": [{"name": "bar"}], + "annotation_reply": {"enabled": False}, + } + return app_model_config + + class TestBaseBackwardsInvocation: def test_convert_to_event_stream_with_generator_and_error(self): def _stream(): @@ -42,12 +53,25 @@ class TestBaseBackwardsInvocation: class TestPluginAppBackwardsInvocation: + def patch_create_session(self, mocker: MockerFixture, *, return_value=None, side_effect=None): + session = MagicMock() + if side_effect is not None: + session.scalar.side_effect = side_effect + else: + session.scalar.return_value = return_value + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = None + mocker.patch("core.plugin.backwards_invocation.app.create_session", return_value=session_ctx) + return session + def test_fetch_app_info_workflow_path(self, mocker: MockerFixture): workflow = MagicMock() workflow.features_dict = {"feature": "v"} workflow.user_input_form.return_value = [{"name": "foo"}] - app = MagicMock(mode=AppMode.WORKFLOW, workflow=workflow) + app = MagicMock(mode=AppMode.WORKFLOW) mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=workflow) mapper = mocker.patch( "core.plugin.backwards_invocation.app.get_parameters_from_feature_dict", return_value={"mapped": True}, @@ -59,10 +83,10 @@ class TestPluginAppBackwardsInvocation: mapper.assert_called_once_with(features_dict={"feature": "v"}, user_input_form=[{"name": "foo"}]) def test_fetch_app_info_model_config_path(self, mocker: MockerFixture): - model_config = MagicMock() - model_config.to_dict.return_value = {"user_input_form": [{"name": "bar"}], "k": "v"} - app = MagicMock(mode=AppMode.COMPLETION, app_model_config=model_config) + model_config_dict = {"user_input_form": [{"name": "bar"}], "k": "v"} + app = MagicMock(mode=AppMode.COMPLETION) mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_app_model_config_dict", return_value=model_config_dict) mocker.patch( "core.plugin.backwards_invocation.app.get_parameters_from_feature_dict", return_value={"mapped": True}, @@ -85,8 +109,10 @@ class TestPluginAppBackwardsInvocation: def test_invoke_app_routes_by_mode(self, mocker: MockerFixture, mode, route_method): app = MagicMock(mode=mode) user = MagicMock() + workflow = MagicMock() mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) mocker.patch.object(PluginAppBackwardsInvocation, "_get_user", return_value=user) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=workflow) route = mocker.patch.object(PluginAppBackwardsInvocation, route_method, return_value={"routed": True}) result = PluginAppBackwardsInvocation.invoke_app( @@ -106,7 +132,9 @@ class TestPluginAppBackwardsInvocation: def test_invoke_app_uses_end_user_when_user_id_missing(self, mocker: MockerFixture): app = MagicMock(mode=AppMode.WORKFLOW) end_user = MagicMock() + workflow = MagicMock() mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=workflow) get_or_create = mocker.patch( "core.plugin.backwards_invocation.app.EndUserService.get_or_create_end_user", return_value=end_user, @@ -126,7 +154,8 @@ class TestPluginAppBackwardsInvocation: assert result == {"ok": True} get_or_create.assert_called_once_with(app) - assert route.call_args.args[1] is end_user + assert route.call_args.args[1] is workflow + assert route.call_args.args[2] is end_user def test_invoke_app_missing_query_for_chat_raises(self, mocker: MockerFixture): mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=MagicMock(mode=AppMode.CHAT)) @@ -190,7 +219,7 @@ class TestPluginAppBackwardsInvocation: app = MagicMock() app.mode = AppMode.ADVANCED_CHAT - app.workflow = workflow + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=workflow) mocker.patch( "core.plugin.backwards_invocation.app.db", @@ -217,8 +246,9 @@ class TestPluginAppBackwardsInvocation: assert isinstance(pause_state_config, PauseStateLayerConfig) assert pause_state_config.state_owner_user_id == "owner-id" - def test_invoke_chat_app_advanced_chat_without_workflow_raises(self): - app = MagicMock(mode=AppMode.ADVANCED_CHAT, workflow=None) + def test_invoke_chat_app_advanced_chat_without_workflow_raises(self, mocker: MockerFixture): + app = MagicMock(mode=AppMode.ADVANCED_CHAT) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=None) with pytest.raises(ValueError, match="unexpected app type"): PluginAppBackwardsInvocation.invoke_chat_app( app=app, @@ -249,7 +279,6 @@ class TestPluginAppBackwardsInvocation: app = MagicMock() app.mode = AppMode.WORKFLOW - app.workflow = workflow mocker.patch( "core.plugin.backwards_invocation.app.db", @@ -262,6 +291,7 @@ class TestPluginAppBackwardsInvocation: result = PluginAppBackwardsInvocation.invoke_workflow_app( app=app, + workflow=workflow, user=MagicMock(), stream=False, inputs={"k": "v"}, @@ -274,12 +304,18 @@ class TestPluginAppBackwardsInvocation: assert isinstance(pause_state_config, PauseStateLayerConfig) assert pause_state_config.state_owner_user_id == "owner-id" - def test_invoke_workflow_app_without_workflow_raises(self): - app = MagicMock(mode=AppMode.WORKFLOW, workflow=None) + def test_invoke_app_workflow_without_workflow_raises(self, mocker: MockerFixture): + app = MagicMock(mode=AppMode.WORKFLOW) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_user", return_value=MagicMock()) + mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=None) with pytest.raises(ValueError, match="unexpected app type"): - PluginAppBackwardsInvocation.invoke_workflow_app( - app=app, - user=MagicMock(), + PluginAppBackwardsInvocation.invoke_app( + app_id="app", + user_id="user", + tenant_id="tenant", + conversation_id=None, + query=None, stream=False, inputs={}, files=[], @@ -297,58 +333,97 @@ class TestPluginAppBackwardsInvocation: assert spy.call_count == 1 def test_get_user_returns_end_user(self, mocker: MockerFixture): - session = MagicMock() - session.scalar.side_effect = [MagicMock(id="end-user")] - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = None - mocker.patch("core.plugin.backwards_invocation.app.Session", return_value=session_ctx) - mocker.patch("core.plugin.backwards_invocation.app.db", SimpleNamespace(engine=MagicMock())) + session = self.patch_create_session(mocker, side_effect=[MagicMock(id="end-user")]) + app = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + user = PluginAppBackwardsInvocation._get_user("uid", app) - user = PluginAppBackwardsInvocation._get_user("uid") assert user.id == "end-user" + stmt = session.scalar.call_args_list[0].args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "end_users.id" in compiled + assert "end_users.tenant_id" in compiled + assert "end_users.app_id" in compiled + assert stmt.compile().params == {"id_1": "uid", "tenant_id_1": "tenant-1", "app_id_1": "app-1"} def test_get_user_falls_back_to_account_user(self, mocker: MockerFixture): - session = MagicMock() - session.scalar.side_effect = [None, MagicMock(id="account-user")] - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = None - mocker.patch("core.plugin.backwards_invocation.app.Session", return_value=session_ctx) - mocker.patch("core.plugin.backwards_invocation.app.db", SimpleNamespace(engine=MagicMock())) + session = self.patch_create_session(mocker, side_effect=[None, MagicMock(id="account-user")]) + app = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + user = PluginAppBackwardsInvocation._get_user("uid", app) - user = PluginAppBackwardsInvocation._get_user("uid") assert user.id == "account-user" + stmt = session.scalar.call_args_list[1].args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "accounts.id" in compiled + assert "tenant_account_joins.account_id" in compiled + assert "tenant_account_joins.tenant_id" in compiled + assert stmt.compile().params == {"id_1": "uid", "tenant_id_1": "tenant-1"} def test_get_user_raises_when_user_not_found(self, mocker: MockerFixture): - session = MagicMock() - session.scalar.side_effect = [None, None] - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = None - mocker.patch("core.plugin.backwards_invocation.app.Session", return_value=session_ctx) - mocker.patch("core.plugin.backwards_invocation.app.db", SimpleNamespace(engine=MagicMock())) + self.patch_create_session(mocker, side_effect=[None, None]) + app = SimpleNamespace(id="app-1", tenant_id="tenant-1") with pytest.raises(ValueError, match="user not found"): - PluginAppBackwardsInvocation._get_user("uid") + PluginAppBackwardsInvocation._get_user("uid", app) def test_get_app_returns_app(self, mocker: MockerFixture): app_obj = MagicMock(id="app") - db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=app_obj))) - mocker.patch("core.plugin.backwards_invocation.app.db", db) + self.patch_create_session(mocker, return_value=app_obj) assert PluginAppBackwardsInvocation._get_app("app", "tenant") is app_obj def test_get_app_raises_when_missing(self, mocker: MockerFixture): - db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=None))) - mocker.patch("core.plugin.backwards_invocation.app.db", db) + self.patch_create_session(mocker, return_value=None) with pytest.raises(ValueError, match="app not found"): PluginAppBackwardsInvocation._get_app("app", "tenant") def test_get_app_raises_when_query_fails(self, mocker: MockerFixture): - db = SimpleNamespace(session=MagicMock(scalar=MagicMock(side_effect=RuntimeError("db down")))) - mocker.patch("core.plugin.backwards_invocation.app.db", db) + self.patch_create_session(mocker, side_effect=RuntimeError("db down")) with pytest.raises(ValueError, match="app not found"): PluginAppBackwardsInvocation._get_app("app", "tenant") + + def test_get_workflow_stays_inside_app_boundary(self, mocker: MockerFixture): + workflow = MagicMock(id="workflow") + session = self.patch_create_session(mocker, return_value=workflow) + app = SimpleNamespace(id="app-1", tenant_id="tenant-1", workflow_id="workflow-1") + + assert PluginAppBackwardsInvocation._get_workflow(app) is workflow + + stmt = session.scalar.call_args.args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "workflows.id" in compiled + assert "workflows.tenant_id" in compiled + assert "workflows.app_id" in compiled + assert stmt.compile().params == { + "id_1": "workflow-1", + "tenant_id_1": "tenant-1", + "app_id_1": "app-1", + "param_1": 1, + } + + def test_get_app_model_config_dict_uses_explicit_session_for_annotation_reply(self, mocker: MockerFixture): + annotation_reply = {"enabled": False} + app_model_config = _build_app_model_config() + session = self.patch_create_session(mocker, return_value=app_model_config) + load_annotation_reply_config = mocker.patch( + "core.plugin.backwards_invocation.app.load_annotation_reply_config", + return_value=annotation_reply, + ) + app = SimpleNamespace(id="app-1", app_model_config_id="config-1") + + result = PluginAppBackwardsInvocation._get_app_model_config_dict(app) + + assert result is not None + assert result["user_input_form"] == [{"name": "bar"}] + assert result["annotation_reply"] == annotation_reply + load_annotation_reply_config.assert_called_once_with(session, "app-1") + app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply) + + stmt = session.scalar.call_args.args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "app_model_configs.id" in compiled + assert "app_model_configs.app_id" in compiled + assert stmt.compile().params == {"id_1": "config-1", "app_id_1": "app-1", "param_1": 1} diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index 5bd35e6d3c2..27c32d47ee2 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -32,6 +32,7 @@ from models.human_input import ( EmailMemberRecipientPayload, HumanInputFormRecipient, RecipientType, + StandaloneWebAppRecipientPayload, ) @@ -307,6 +308,9 @@ class _DummyRecipient: recipient_type: RecipientType access_token: str form: _DummyForm | None = None + recipient_payload: str = dataclasses.field( + default_factory=lambda: StandaloneWebAppRecipientPayload().model_dump_json() + ) class _FakeScalarResult: diff --git a/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py new file mode 100644 index 00000000000..f53a8eba734 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py @@ -0,0 +1,63 @@ +import pytest + +from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons +from graphon.entities.pause_reason import PauseReasonType + +_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"} + + +@pytest.mark.parametrize( + ("dispositions", "expected_token", "expected_channels"), + [ + ({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]), + ({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []), + # form_id absent from the map (no recipient rows) falls back to no token, no channels. + ({}, None, []), + ], +) +def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels): + out = enrich_human_input_pause_reasons( + [dict(_HUMAN_INPUT_REASON)], + dispositions_by_form_id=dispositions, + expiration_times_by_form_id={}, + ) + + assert out[0]["form_token"] == expected_token + assert out[0]["approval_channels"] == expected_channels + + +def test_enrich_leaves_non_human_input_reasons_untouched(): + reason = {"TYPE": "something_else", "form_id": "f1"} + + out = enrich_human_input_pause_reasons( + [reason], + dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])}, + expiration_times_by_form_id={}, + ) + + assert out[0] == reason + assert "form_token" not in out[0] + assert "approval_channels" not in out[0] + + +def test_pause_reason_payload_carries_approval_channels_through_factory(): + # from_response_data maps fields by hand; this guards approval_channels/form_token + # (the fields this feature added) against being dropped in that mapping. + from core.app.entities.task_entities import ( + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, + ) + + data = HumanInputRequiredResponse.Data( + form_id="f", + node_id="n", + node_title="t", + form_content="c", + expiration_time=123, + form_token=None, + approval_channels=["console"], + ) + payload = HumanInputRequiredPauseReasonPayload.from_response_data(data) + + assert payload.approval_channels == ["console"] + assert payload.form_token is None 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 e508815b35e..c84c7d578be 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,7 +1,16 @@ from types import SimpleNamespace -from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id -from core.workflow.human_input_policy import HumanInputSurface +import pytest + +from core.workflow.human_input_forms import ( + load_form_dispositions_by_form_id, + load_form_tokens_by_form_id, +) +from core.workflow.human_input_policy import ( + FormDisposition, + HumanInputSurface, + disposition_for_surface, +) from models.human_input import RecipientType @@ -13,91 +22,100 @@ class _FakeSession: return self._recipients -def test_load_form_tokens_by_form_id_prefers_backstage_token() -> None: +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) + + +@pytest.mark.parametrize( + ("surface", "expected_token"), + [ + # Unfiltered (no surface) picks the highest-priority recipient: backstage. + (None, "backstage-token"), + # SERVICE_API may only act on the web-app recipient. + (HumanInputSurface.SERVICE_API, "web-token"), + ], +) +def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None: session = _FakeSession( - recipients=[ - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.STANDALONE_WEB_APP, - access_token="web-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.CONSOLE, - access_token="console-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.BACKSTAGE, - access_token="backstage-token", - ), + [ + _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) == {"form-1": "backstage-token"} + assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token} -def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None: +def test_load_form_tokens_drops_forms_without_actionable_token() -> None: session = _FakeSession( - recipients=[ - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.EMAIL_MEMBER, - access_token="email-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.CONSOLE, - access_token=None, - ), + [ + _recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"), + _recipient("form-1", RecipientType.CONSOLE, None), ] ) assert load_form_tokens_by_form_id(["form-1"], session=session) == {} -def test_load_form_tokens_by_form_id_uses_shared_priority() -> None: +def test_load_form_tokens_service_api_surface_uses_web_token() -> None: session = _FakeSession( - recipients=[ - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.STANDALONE_WEB_APP, - access_token="web-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.CONSOLE, - access_token="console-token", - ), + [ + _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(session, ["form-1"]) == {"form-1": "console-token"} + assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == { + "form-1": "web-token" + } -def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None: +def test_load_dispositions_openapi_webapp_form_is_resumable() -> None: session = _FakeSession( - recipients=[ - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.STANDALONE_WEB_APP, - access_token="web-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.CONSOLE, - access_token="console-token", - ), - SimpleNamespace( - form_id="form-1", - recipient_type=RecipientType.BACKSTAGE, - access_token="backstage-token", - ), + [ + _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), + _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), ] ) - assert load_form_tokens_by_form_id( - ["form-1"], - session=session, - surface=HumanInputSurface.SERVICE_API, - ) == {"form-1": "web-token"} + assert load_form_dispositions_by_form_id(["form-1"], session=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")]) + + assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == { + "form-1": FormDisposition(form_token=None, approval_channels=["console"]) + } + + +# disposition_for_surface partitions recipients into a surface-actionable resume +# token plus the approval channels of the recipients the surface may NOT act on. +_WEB = (RecipientType.STANDALONE_WEB_APP, "tok_web") +_BACKSTAGE = (RecipientType.BACKSTAGE, "tok_b") +_CONSOLE = (RecipientType.CONSOLE, "tok_c") +_EMAIL_MEMBER = (RecipientType.EMAIL_MEMBER, "t1") +_EMAIL_EXTERNAL = (RecipientType.EMAIL_EXTERNAL, "t2") + + +@pytest.mark.parametrize( + ("recipients", "surface", "expected"), + [ + # Token surface acts on the web-app recipient; blocked recipients become channels. + ([_BACKSTAGE, _WEB], HumanInputSurface.OPENAPI, FormDisposition("tok_web", ["console"])), + ([_EMAIL_MEMBER, _EMAIL_EXTERNAL], HumanInputSurface.OPENAPI, FormDisposition(None, ["email"])), + ([_EMAIL_MEMBER, _BACKSTAGE], HumanInputSurface.OPENAPI, FormDisposition(None, ["console", "email"])), + # CONSOLE acts on console/backstage; a web-app recipient is blocked → web_app channel. + ([_CONSOLE, _WEB], HumanInputSurface.CONSOLE, FormDisposition("tok_c", ["web_app"])), + ([_WEB], HumanInputSurface.CONSOLE, FormDisposition(None, ["web_app"])), + # No surface: unfiltered priority token, channels never populated. + ([_BACKSTAGE], None, FormDisposition("tok_b", [])), + ([_WEB, _EMAIL_MEMBER], None, FormDisposition("tok_web", [])), + ], +) +def test_disposition_for_surface_partitions_token_and_channels(recipients, surface, expected) -> None: + assert disposition_for_surface(recipients, surface=surface) == expected diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py index 651b69216ae..a1c6fee98cf 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_policy.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -12,38 +12,28 @@ from graphon.runtime import VariablePool from models.human_input import RecipientType -def test_service_api_only_allows_public_webapp_forms() -> None: - assert is_recipient_type_allowed_for_surface( - RecipientType.STANDALONE_WEB_APP, - HumanInputSurface.SERVICE_API, - ) - assert not is_recipient_type_allowed_for_surface( - RecipientType.CONSOLE, - HumanInputSurface.SERVICE_API, - ) - assert not is_recipient_type_allowed_for_surface( - RecipientType.BACKSTAGE, - HumanInputSurface.SERVICE_API, - ) - assert not is_recipient_type_allowed_for_surface( - RecipientType.EMAIL_MEMBER, - HumanInputSurface.SERVICE_API, - ) - - -def test_console_only_allows_internal_console_surfaces() -> None: - assert is_recipient_type_allowed_for_surface( - RecipientType.CONSOLE, - HumanInputSurface.CONSOLE, - ) - assert is_recipient_type_allowed_for_surface( - RecipientType.BACKSTAGE, - HumanInputSurface.CONSOLE, - ) - assert not is_recipient_type_allowed_for_surface( - RecipientType.STANDALONE_WEB_APP, - HumanInputSurface.CONSOLE, - ) +# Token surfaces (SERVICE_API, OPENAPI) may act only on public web-app forms; +# CONSOLE may act on internal console/backstage forms. OPENAPI mirrors SERVICE_API +# today but is pinned independently because the two are expected to diverge. +@pytest.mark.parametrize( + ("recipient_type", "surface", "allowed"), + [ + (RecipientType.STANDALONE_WEB_APP, HumanInputSurface.SERVICE_API, True), + (RecipientType.CONSOLE, HumanInputSurface.SERVICE_API, False), + (RecipientType.BACKSTAGE, HumanInputSurface.SERVICE_API, False), + (RecipientType.EMAIL_MEMBER, HumanInputSurface.SERVICE_API, False), + (RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI, True), + (RecipientType.CONSOLE, HumanInputSurface.OPENAPI, False), + (RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI, False), + (RecipientType.CONSOLE, HumanInputSurface.CONSOLE, True), + (RecipientType.BACKSTAGE, HumanInputSurface.CONSOLE, True), + (RecipientType.STANDALONE_WEB_APP, HumanInputSurface.CONSOLE, False), + ], +) +def test_recipient_type_allowed_per_surface( + recipient_type: RecipientType, surface: HumanInputSurface, allowed: bool +) -> None: + assert is_recipient_type_allowed_for_surface(recipient_type, surface) is allowed def test_preferred_form_token_uses_shared_priority_order() -> None: @@ -56,6 +46,17 @@ def test_preferred_form_token_uses_shared_priority_order() -> None: assert get_preferred_form_token(recipients) == "backstage-token" +def test_preferred_form_token_skips_prioritized_type_with_empty_token() -> None: + # An empty token is not actionable: the highest-priority recipient that + # actually carries a token wins, not the highest-priority type. + recipients = [ + (RecipientType.BACKSTAGE, ""), + (RecipientType.CONSOLE, "console-token"), + ] + + assert get_preferred_form_token(recipients) == "console-token" + + def test_resolve_variable_select_input_options_uses_runtime_values() -> None: variable_pool = VariablePool() variable_pool.add(("start", "options"), ["approve", "reject"]) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py b/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py deleted file mode 100644 index b78e821237d..00000000000 --- a/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms.""" - -from __future__ import annotations - -from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface -from models.human_input import RecipientType - - -def test_openapi_surface_exists(): - assert HumanInputSurface.OPENAPI == "openapi" - - -def test_openapi_allows_standalone_web_app(): - assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI) - - -def test_openapi_rejects_console_recipient(): - assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI) - - -def test_openapi_rejects_backstage_recipient(): - assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI) - - -def test_get_surface_form_token_openapi_picks_standalone_web_app(): - """OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API.""" - from core.workflow.human_input_forms import _get_surface_form_token - - recipients = [ - (RecipientType.BACKSTAGE, "backstage-token"), - (RecipientType.STANDALONE_WEB_APP, "web-token"), - ] - token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI) - assert token == "web-token" diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py index 24bbf55cb3a..2884509d22b 100644 --- a/api/tests/unit_tests/extensions/test_pubsub_channel.py +++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py @@ -2,7 +2,7 @@ import pytest from configs import dify_config from extensions import ext_redis -from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel +from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py index 7c7f20374ee..7ab54555294 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py @@ -18,13 +18,10 @@ from unittest.mock import MagicMock, patch import pytest from libs.broadcast_channel.exc import BroadcastChannelError, SubscriptionClosedError -from libs.broadcast_channel.redis.channel import ( +from libs.broadcast_channel.redis.pubsub_channel import ( BroadcastChannel as RedisBroadcastChannel, ) -from libs.broadcast_channel.redis.channel import ( - Topic, - _RedisSubscription, -) +from libs.broadcast_channel.redis.pubsub_channel import Topic, _RedisSubscription from libs.broadcast_channel.redis.sharded_channel import ( ShardedRedisBroadcastChannel, ShardedTopic, diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py index 95085eaf673..9a8cb861abf 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py @@ -77,11 +77,28 @@ class FailExpireRedis(FakeStreamsRedis): class BlockingRedis: + """A Redis mock whose xread blocks until a control event is xadd-ed.""" + def __init__(self) -> None: self._release = threading.Event() + self._store: dict[str, list[tuple[str, dict]]] = {} + self._next_id: dict[str, int] = {} + + def xadd(self, key: str, fields: dict[str, Any], *, maxlen: int | None = None) -> str: + n = self._next_id.get(key, 0) + 1 + self._next_id[key] = n + entry_id = f"{n}-0" + self._store.setdefault(key, []).append((entry_id, fields)) + self._release.set() # Wake up any blocked xread + return entry_id def xread(self, streams: dict[str, Any], block: int | None = None, count: int | None = None): self._release.wait(timeout=block / 1000.0 if block else None) + key = next(iter(streams)) + entries = self._store.get(key, []) + if entries: + self._store[key] = [] # Consume entries + return [(key, entries)] return [] def release(self) -> None: @@ -176,48 +193,6 @@ class TestStreamsBroadcastChannel: assert topic.as_producer() is topic assert topic.as_subscriber() is topic - def test_join_timeout_ms_propagates_from_channel_to_subscription(self, fake_redis: FakeStreamsRedis): - channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=150) - topic = channel.topic("join-timeout-prop") - - assert topic._join_timeout_ms == 150 - - sub = topic.subscribe() - try: - assert sub._join_timeout_ms == 150 - finally: - sub.close() - - def test_join_timeout_ms_defaults_to_2000(self, fake_redis: FakeStreamsRedis): - channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60) - topic = channel.topic("join-timeout-default") - - assert topic._join_timeout_ms == 2000 - - def test_small_join_timeout_makes_close_return_promptly(self, fake_redis: FakeStreamsRedis): - """close() should respect the configured join timeout. - - Regression test for SSE close tail latency: when an idle listener is - blocked on its poll cycle, close() with a small join_timeout_ms must - not wait for the full poll window. The orphaned daemon listener - cleans itself up later. - """ - channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=50) - topic = channel.topic("join-timeout-prompt-close") - sub = topic.subscribe() - - # Drive listener startup so the thread is actually blocked in xread. - assert sub.receive(timeout=0.05) is None - time.sleep(0.05) - - started = time.monotonic() - sub.close() - elapsed = time.monotonic() - started - - # 50ms timeout + scheduling slack; pick a ceiling well under the - # default poll window (1000ms) to make the regression meaningful. - assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return" - def test_publish_logs_warning_when_expire_fails(self, caplog: pytest.LogCaptureFixture): channel = StreamsBroadcastChannel(FailExpireRedis(), retention_seconds=60) topic = channel.topic("expire-warning") @@ -384,40 +359,32 @@ class TestStreamsSubscription: assert next(iter(subscription)) == b"event" - def test_close_logs_debug_when_listener_does_not_stop_in_time( - self, - caplog: pytest.LogCaptureFixture, - ): - """When a low join_timeout elapses with the listener still alive, - close() should log at DEBUG (not WARNING) - with a deliberately small - timeout this is expected, not anomalous; the orphaned daemon thread - cleans itself up on the next poll boundary. + def test_control_event_unblocks_listener_for_prompt_close(self): + """close() returns promptly because the control event (xadd) unblocks + the listener from its blocking xread call. """ - import logging - blocking_redis = BlockingRedis() - subscription = _StreamsSubscription(blocking_redis, "stream:slow-close") + subscription = _StreamsSubscription(blocking_redis, "stream:prompt-close") + # Drive listener startup so the thread is blocked in xread. subscription._start_if_needed() listener = subscription._listener assert listener is not None + assert listener.is_alive() - original_join = listener.join - original_is_alive = listener.is_alive + started = time.monotonic() + subscription.close() + elapsed = time.monotonic() - started - def delayed_join(timeout: float | None = None) -> None: - original_join(0.01) + # The control event (xadd) wakes up xread immediately, so close() + # should return well under 1s (the xread BLOCK timeout). + assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return via control event" - listener.join = delayed_join # type: ignore[method-assign] - listener.is_alive = lambda: True # type: ignore[method-assign] + def test_control_event_not_sent_when_listener_not_started(self): + """close() should not fail when the listener was never started.""" + subscription = _StreamsSubscription(FakeStreamsRedis(), "stream:no-listener") + subscription.close() - try: - with caplog.at_level(logging.DEBUG, logger="libs.broadcast_channel.redis.streams_channel"): - subscription.close() - assert "did not stop within" in caplog.text - assert "daemon thread will exit on its own" in caplog.text - finally: - listener.join = original_join # type: ignore[method-assign] - listener.is_alive = original_is_alive # type: ignore[method-assign] - blocking_redis.release() - original_join(timeout=1) + assert subscription._listener is None + with pytest.raises(SubscriptionClosedError): + subscription.receive(timeout=0.01) diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py index aabbd4df300..422a3218eaa 100644 --- a/api/tests/unit_tests/models/test_agent.py +++ b/api/tests/unit_tests/models/test_agent.py @@ -33,6 +33,7 @@ def test_agent_enums_match_prd_boundaries(): assert AgentStatus.ACTIVE.value == "active" assert AgentStatus.ARCHIVED.value == "archived" assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version" + assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version" assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent" assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent" diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index d3d0c5dce00..684d7f9fa8e 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -12,10 +12,11 @@ import json from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from uuid import uuid4 import pytest +from sqlalchemy.dialects import postgresql from models.enums import ConversationFromSource from models.model import ( @@ -29,6 +30,7 @@ from models.model import ( Message, MessageAnnotation, Site, + load_annotation_reply_config, ) @@ -342,6 +344,70 @@ class TestAppModelConfig: # Assert assert result == questions + def test_to_dict_uses_injected_annotation_reply(self): + config = AppModelConfig(app_id=str(uuid4())) + annotation_reply = {"enabled": False} + + with patch.object( + AppModelConfig, + "annotation_reply_dict", + new_callable=PropertyMock, + side_effect=AssertionError("annotation_reply_dict should not be accessed"), + ): + result = config.to_dict(annotation_reply=annotation_reply) + + assert result["annotation_reply"] == annotation_reply + + +class TestAnnotationReplyConfigLoader: + def test_load_annotation_reply_config_returns_disabled_when_setting_missing(self): + session = MagicMock() + session.scalar.return_value = None + + result = load_annotation_reply_config(session, "app-1") + + assert result == {"enabled": False} + session.scalar.assert_called_once() + stmt = session.scalar.call_args.args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "app_annotation_settings.app_id" in compiled + assert stmt.compile().params == {"app_id_1": "app-1"} + + def test_load_annotation_reply_config_returns_embedding_model(self): + session = MagicMock() + annotation_setting = SimpleNamespace( + id="annotation-1", + score_threshold=0.7, + collection_binding_id="binding-1", + ) + collection_binding = SimpleNamespace(provider_name="provider", model_name="embedding") + session.scalar.side_effect = [annotation_setting, collection_binding] + + result = load_annotation_reply_config(session, "app-1") + + assert result == { + "id": "annotation-1", + "enabled": True, + "score_threshold": 0.7, + "embedding_model": { + "embedding_provider_name": "provider", + "embedding_model_name": "embedding", + }, + } + assert session.scalar.call_count == 2 + stmt = session.scalar.call_args_list[1].args[0] + compiled = str(stmt.compile(dialect=postgresql.dialect())) + assert "dataset_collection_bindings.id" in compiled + assert stmt.compile().params == {"id_1": "binding-1"} + + def test_load_annotation_reply_config_raises_when_binding_missing(self): + session = MagicMock() + annotation_setting = SimpleNamespace(collection_binding_id="binding-1") + session.scalar.side_effect = [annotation_setting, None] + + with pytest.raises(ValueError, match="Collection binding detail not found"): + load_annotation_reply_config(session, "app-1") + class TestConversationModel: """Test suite for Conversation model integrity.""" diff --git a/api/tests/unit_tests/models/test_recipient_type_label.py b/api/tests/unit_tests/models/test_recipient_type_label.py new file mode 100644 index 00000000000..3e98c17ca99 --- /dev/null +++ b/api/tests/unit_tests/models/test_recipient_type_label.py @@ -0,0 +1,21 @@ +import pytest + +from models.human_input import ApprovalChannel, RecipientType + + +@pytest.mark.parametrize( + ("recipient_type", "expected_channel"), + [ + (RecipientType.EMAIL_MEMBER, ApprovalChannel.EMAIL), + (RecipientType.EMAIL_EXTERNAL, ApprovalChannel.EMAIL), + (RecipientType.CONSOLE, ApprovalChannel.CONSOLE), + (RecipientType.BACKSTAGE, ApprovalChannel.CONSOLE), + (RecipientType.STANDALONE_WEB_APP, ApprovalChannel.WEB_APP), + ], +) +def test_approval_channel_collapses_delivery_types( + recipient_type: RecipientType, expected_channel: ApprovalChannel +) -> None: + # Both email types collapse to EMAIL and console/backstage to CONSOLE: + # the user-facing approval channel, not the internal recipient type. + assert recipient_type.approval_channel == expected_channel 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 2c077e20b46..52ff00c0855 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -8,6 +8,7 @@ from models.agent import ( Agent, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentKind, AgentScope, AgentSource, @@ -23,7 +24,8 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) -from models.model import IconType +from models.enums import ConversationFromSource, ConversationStatus +from models.model import Conversation, IconType from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -532,6 +534,100 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch assert inline_agent.updated_by == "account-1" +def test_node_job_only_switches_roster_binding_to_inline_agent(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + created_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") + captured: dict[str, object] = {} + + def fake_create_workflow_only_agent(**kwargs): + captured.update(kwargs) + return created_agent + + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) + existing_node_job = WorkflowNodeJobConfig(workflow_prompt="keep the existing task") + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=existing_node_job, + created_by="account-1", + updated_by="account-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "binding": {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value}, + "agent_soul": {"prompt": {"system_prompt": "start from scratch"}}, + } + ) + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + assert updated_binding is binding + assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + assert binding.agent_id == "inline-agent-1" + assert binding.current_snapshot_id == "inline-version-1" + assert binding.node_job_config is existing_node_job + assert binding.updated_by == "account-1" + assert captured["tenant_id"] == "tenant-1" + assert captured["app_id"] == "app-1" + assert captured["workflow_id"] == "workflow-1" + assert captured["node_id"] == "node-1" + assert captured["account_id"] == "account-1" + assert captured["agent_soul"].prompt.system_prompt == "start from scratch" + assert fake_session.flushes == 1 + + +def test_node_job_only_rejects_start_from_scratch_with_existing_inline_binding_id(): + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=WorkflowNodeJobConfig(), + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "binding": { + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + "agent_id": "existing-inline-agent", + }, + } + ) + + with pytest.raises(ValueError, match="Start from Scratch"): + AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -998,6 +1094,11 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch scalars=[[AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1)]], ) service = AgentRosterService(fake_session) + monkeypatch.setattr( + AgentRosterService, + "_get_or_create_agent_app_debug_conversation", + lambda self, *, agent, account_id: "debug-conversation-1", + ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", description="desc", @@ -1039,6 +1140,135 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch assert loaded_versions["version-1"].agent_id == "agent-1" +def test_agent_app_debug_conversation_create_reuse_and_recreate(): + 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, + ) + + create_session = FakeSession(scalar=[agent, None]) + created_id = AgentRosterService(create_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + created_conversation = next(value for value in create_session.added if isinstance(value, Conversation)) + created_mapping = next(value for value in create_session.added if isinstance(value, AgentDebugConversation)) + assert created_id == created_mapping.conversation_id + assert created_conversation.app_id == "app-1" + assert created_conversation.from_account_id == "account-1" + assert created_mapping.tenant_id == "tenant-1" + assert created_mapping.agent_id == "agent-1" + assert created_mapping.account_id == "account-1" + assert create_session.commits == 1 + + existing_mapping = AgentDebugConversation( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-1", + account_id="account-1", + conversation_id="existing-conversation", + ) + reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"]) + reused_id = AgentRosterService(reuse_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + assert reused_id == "existing-conversation" + assert reuse_session.added == [] + assert reuse_session.commits == 1 + + stale_mapping = AgentDebugConversation( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-1", + account_id="account-1", + conversation_id="deleted-conversation", + ) + recreate_session = FakeSession(scalar=[agent, stale_mapping, None]) + recreated_id = AgentRosterService(recreate_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + assert recreated_id == stale_mapping.conversation_id + assert recreated_id != "deleted-conversation" + assert any(isinstance(value, Conversation) for value in recreate_session.added) + assert recreate_session.commits == 1 + + +def test_agent_app_debug_conversation_requires_app_binding(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + app_id=None, + name="Analyst", + description="", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + + with pytest.raises(roster_service.AgentNotFoundError): + AgentRosterService(FakeSession())._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id="account-1", + ) + + +def test_load_or_create_agent_app_debug_conversations_filters_agent_apps(): + valid_agent = Agent( + id="agent-1", + tenant_id="tenant-1", + app_id="app-1", + name="Analyst", + description="", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + wrong_tenant_agent = Agent( + id="agent-2", + tenant_id="tenant-2", + app_id="app-2", + name="Other tenant", + description="", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + workflow_agent = Agent( + id="agent-3", + tenant_id="tenant-1", + app_id=None, + name="Workflow only", + description="", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + ) + + fake_session = FakeSession(scalar=[None]) + result = AgentRosterService(fake_session).load_or_create_agent_app_debug_conversation_ids_by_agent_id( + tenant_id="tenant-1", + agents=[valid_agent, wrong_tenant_agent, workflow_agent], + account_id="account-1", + ) + + assert list(result) == ["agent-1"] + assert result["agent-1"] + assert fake_session.commits == 1 + assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 1 + + def test_agent_app_visible_versions_exclude_draft_saves(): agent_app = Agent(source=AgentSource.AGENT_APP) roster_agent = Agent(source=AgentSource.ROSTER) @@ -1046,12 +1276,93 @@ def test_agent_app_visible_versions_exclude_draft_saves(): agent_app_operations = AgentRosterService._visible_version_operations(agent_app) roster_operations = AgentRosterService._visible_version_operations(roster_agent) - assert agent_app_operations == {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + assert agent_app_operations == { + AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.RESTORE_VERSION, + } assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations + assert AgentConfigRevisionOperation.RESTORE_VERSION in roster_operations assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations +def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession(scalar=["version-2", 6]) + service = AgentRosterService(fake_session) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Analyst", + description="old", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-4", + ) + version = AgentConfigSnapshot( + id="version-2", + tenant_id="tenant-1", + agent_id="agent-1", + version=2, + config_snapshot=_agent_soul_with_model(), + ) + + monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent) + monkeypatch.setattr(service, "_get_version", lambda **kwargs: version) + + restored = service.restore_agent_version( + tenant_id="tenant-1", + agent_id="agent-1", + version_id="version-2", + account_id="account-1", + ) + + assert restored == {"result": "success", "active_config_snapshot_id": "version-2"} + assert agent.active_config_snapshot_id == "version-2" + assert agent.active_config_has_model is True + assert agent.updated_by == "account-1" + assert fake_session.commits == 1 + revision = fake_session.added[0] + assert revision.tenant_id == "tenant-1" + assert revision.agent_id == "agent-1" + assert revision.previous_snapshot_id == "version-4" + assert revision.current_snapshot_id == "version-2" + assert revision.revision == 7 + assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION + assert revision.created_by == "account-1" + + +def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession(scalar=[None]) + service = AgentRosterService(fake_session) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Analyst", + description="old", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-4", + ) + + monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent) + + with pytest.raises(roster_service.AgentVersionNotFoundError): + service.restore_agent_version( + tenant_id="tenant-1", + agent_id="agent-1", + version_id="version-2", + account_id="account-1", + ) + + assert agent.active_config_snapshot_id == "version-4" + assert fake_session.added == [] + assert fake_session.commits == 0 + + def test_app_list_all_excludes_agent_apps_by_default(): filters = AppService._build_app_list_filters( "account-1", "tenant-1", AppListParams(mode="all"), FakeSession(scalar=None, scalars=None) @@ -1281,6 +1592,20 @@ class TestAgentAppBackingAgent: a for a in session.added if getattr(a, "operation", None) == AgentConfigRevisionOperation.CREATE_VERSION ] assert len(revisions) == 1 + conversations = [a for a in session.added if isinstance(a, Conversation)] + assert len(conversations) == 1 + assert conversations[0].app_id == "app-1" + assert conversations[0].mode == "agent" + assert conversations[0].status == ConversationStatus.NORMAL + assert conversations[0].from_source == ConversationFromSource.CONSOLE + assert conversations[0].from_account_id == "account-1" + debug_mappings = [a for a in session.added if isinstance(a, AgentDebugConversation)] + assert len(debug_mappings) == 1 + assert debug_mappings[0].tenant_id == "tenant-1" + assert debug_mappings[0].agent_id == agent.id + assert debug_mappings[0].app_id == "app-1" + assert debug_mappings[0].account_id == "account-1" + assert debug_mappings[0].conversation_id == conversations[0].id # Caller (AppService.create_app) owns the commit — helper must not commit. assert session.commits == 0 diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index 128a6c42801..29b4c7e59d6 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -67,6 +67,10 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"] assert all(item.value_owned_by_drive for item in items) assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"] + assert items[0].is_skill is True + assert items[0].skill_metadata.name == "PDF Toolkit" + assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"] + assert items[1].is_skill is False # The returned skill ref carries stable drive paths + file ids. skill = result["skill"] 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 aa4780af0b4..35f0d3ac674 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -620,6 +620,45 @@ class TestMyPermissions: assert out.dataset.default_permission_keys == dataset_keys assert out.app.overrides == [] assert out.dataset.overrides == [] + if role == "owner": + assert "billing.view" in out.workspace.permission_keys + assert "snippets.management" in out.workspace.permission_keys + assert "app.acl.preview" in out.workspace.permission_keys + assert "dataset.acl.preview" in out.workspace.permission_keys + assert "app.acl.preview" in out.app.default_permission_keys + assert "dataset.acl.preview" in out.dataset.default_permission_keys + + @pytest.mark.parametrize( + ("role", "expected_snippet_keys"), + [ + ("owner", {"snippets.create_and_modify", "snippets.management"}), + ("admin", {"snippets.create_and_modify", "snippets.management"}), + ("editor", {"snippets.create_and_modify"}), + ("normal", set()), + ("dataset_operator", set()), + ], + ) + def test_get_uses_legacy_snippet_permissions_when_rbac_disabled( + self, + mock_send: MagicMock, + role: str, + expected_snippet_keys: set[str], + ): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = role + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + actual_snippet_keys = { + permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.") + } + + 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() @@ -668,7 +707,8 @@ class TestMemberRoles: } ], } - out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/members/rbac-roles" @@ -676,6 +716,33 @@ 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 + + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session") as create_session, + ): + create_session.return_value.__enter__.return_value = session + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") + + mock_send.assert_not_called() + assert out.account_id == "acct-2" + assert out.roles[0].name == "editor" + assert out.roles[0].permission_keys == list( + dict.fromkeys( + [ + *svc._LEGACY_WORKSPACE_EDITOR_KEYS, + *svc._LEGACY_APP_EDITOR_KEYS, + *svc._LEGACY_DATASET_EDITOR_KEYS, + ] + ) + ) + assert "snippets.create_and_modify" in out.roles[0].permission_keys + 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): mock_send.return_value = {"account_id": "acct-2", "roles": []} svc.RBACService.MemberRoles.replace( diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index 09cde917946..afc3f08124a 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -23,6 +23,7 @@ from services.agent_drive_service import ( AgentDriveError, AgentDriveService, DriveCommitItem, + DriveSkillMetadata, normalize_drive_key, parse_agent_drive_ref, ) @@ -515,3 +516,104 @@ def test_manifest_items_carry_created_at_for_inspector(): _commit("files/x.txt", tf) items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT) assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int) + + +# ── DIFY-2517: skill catalog / inspect ─────────────────────────────────────── + + +def _commit_skill(*, manifest_files: list[str] | None = None) -> None: + md = _seed_tool_file(name="SKILL.md") + zf = _seed_tool_file(name="full.zip") + AgentDriveService().commit( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key="pdf-toolkit/SKILL.md", + file_ref={"kind": "tool_file", "id": md}, + value_owned_by_drive=True, + is_skill=True, + skill_metadata=DriveSkillMetadata( + name="PDF Toolkit", + description="Work with PDFs.", + manifest_files=manifest_files, + ), + ), + DriveCommitItem( + key="pdf-toolkit/.DIFY-SKILL-FULL.zip", + file_ref={"kind": "tool_file", "id": zf}, + value_owned_by_drive=True, + ), + ], + ) + + +def test_list_skills_uses_canonical_skill_rows(): + _commit_skill(manifest_files=["SKILL.md", "scripts/run.py"]) + + skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT) + + created_at = skills[0].pop("created_at") + assert skills == [ + { + "path": "pdf-toolkit", + "skill_md_key": "pdf-toolkit/SKILL.md", + "archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip", + "name": "PDF Toolkit", + "description": "Work with PDFs.", + "size": 5, + "mime_type": "text/plain", + "hash": None, + } + ] + assert created_at is None or isinstance(created_at, int) + + +def test_inspect_skill_returns_manifest_files_and_file_tree(): + _commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"]) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"]) + result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit") + + assert result["source"] == "skill_md" + assert result["warnings"] == [] + assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"] + assert result["files"][0]["available_in_drive"] is True + assert result["files"][1]["available_in_drive"] is False + assert result["file_tree"][0]["name"] == "references" + assert result["file_tree"][1]["name"] == "scripts" + assert result["file_tree"][2]["name"] == "SKILL.md" + assert result["skill_md"]["text"] == "# PDF Toolkit\n" + + +def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing(): + _commit_skill(manifest_files=None) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"]) + result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit") + + assert result["warnings"] == ["manifest_files_unavailable"] + assert [file["path"] for file in result["files"]] == ["SKILL.md"] + + +def test_skill_metadata_rejects_non_canonical_rows(): + tf = _seed_tool_file(name="not-skill.md") + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().commit( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key="files/not-skill.md", + file_ref={"kind": "tool_file", "id": tf}, + value_owned_by_drive=True, + is_skill=True, + skill_metadata=DriveSkillMetadata(name="Bad"), + ) + ], + ) + assert exc_info.value.code == "invalid_skill_key" diff --git a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py index 4293be8f726..4fc08516d3d 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py +++ b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py @@ -109,7 +109,7 @@ def _patch_get_channel_streams(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def _patch_get_channel_pubsub(monkeypatch: pytest.MonkeyPatch): - from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel + from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel store: dict[str, deque[bytes]] = defaultdict(deque) client = _FakeRedisClient(store) diff --git a/api/tests/unit_tests/services/test_credit_pool_service.py b/api/tests/unit_tests/services/test_credit_pool_service.py new file mode 100644 index 00000000000..5e589804c3d --- /dev/null +++ b/api/tests/unit_tests/services/test_credit_pool_service.py @@ -0,0 +1,304 @@ +from collections.abc import Generator +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from core.errors.error import QuotaExceededError +from models import TenantCreditPool +from models.enums import ProviderQuotaType +from services.credit_pool_service import ( + CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS, + CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS, + CreditPoolService, +) + + +def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engine, str, str]: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + tenant_id = str(uuid4()) + pool_id = str(uuid4()) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": pool_id, + "tenant_id": tenant_id, + "pool_type": ProviderQuotaType.TRIAL, + "quota_limit": quota_limit, + "quota_used": quota_used, + }, + ) + return engine, tenant_id, pool_id + + +@contextmanager +def _patched_session_factory(engine: Engine) -> Generator[None, None, None]: + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker): + yield + + +def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None: + with engine.connect() as connection: + return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id)) + + +def _make_session_maker(session: MagicMock) -> MagicMock: + session_maker = MagicMock() + transaction = session_maker.begin.return_value + transaction.__enter__.return_value = session + transaction.__exit__.return_value = None + return session_maker + + +def _make_redis_lock() -> MagicMock: + lock = MagicMock() + lock.acquire.return_value = True + return lock + + +def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None: + engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with _patched_session_factory(engine): + pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL) + + assert pool is not None + assert pool.tenant_id == tenant_id + assert pool.quota_used == 2 + + +def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 3 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 5 + + +def test_check_and_deduct_credits_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_check_and_deduct_credits_raises_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="Credit pool not found"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=1) + + +def test_check_and_deduct_credits_raises_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="No credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_check_and_deduct_credits_raises_without_partial_deduction_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="Insufficient credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 9 + + +def test_check_and_deduct_credits_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=1) + + assert deducted_credits == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert deducted_credits == 0 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_deducts_only_remaining_balance_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 1 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=QuotaExceededError("quota unavailable")), + pytest.raises(QuotaExceededError, match="quota unavailable"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_check_and_deduct_credits_uses_tenant_redis_lock_before_db_deduction() -> None: + tenant_id = "tenant-1" + session = MagicMock() + session_maker = _make_session_maker(session) + pool = SimpleNamespace(remaining_credits=10, quota_used=2) + redis_lock = _make_redis_lock() + + with ( + patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock) as lock, + patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker), + patch.object(CreditPoolService, "_get_locked_pool", return_value=pool) as get_locked_pool, + ): + result = CreditPoolService.check_and_deduct_credits( + tenant_id=tenant_id, + credits_required=3, + pool_type=ProviderQuotaType.TRIAL, + ) + + assert result == 3 + assert pool.quota_used == 5 + lock.assert_called_once_with( + "credit_pool:tenant:tenant-1:deduct_lock", + timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS, + blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS, + ) + redis_lock.acquire.assert_called_once_with(blocking=True) + redis_lock.release.assert_called_once_with() + get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL) + + +def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction() -> None: + tenant_id = "tenant-1" + session = MagicMock() + session_maker = _make_session_maker(session) + pool = SimpleNamespace(remaining_credits=2, quota_used=8) + redis_lock = _make_redis_lock() + + with ( + patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock) as lock, + patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker), + patch.object(CreditPoolService, "_get_locked_pool", return_value=pool) as get_locked_pool, + ): + result = CreditPoolService.deduct_credits_capped( + tenant_id=tenant_id, + credits_required=5, + pool_type=ProviderQuotaType.PAID, + ) + + assert result == 2 + assert pool.quota_used == 10 + lock.assert_called_once_with( + "credit_pool:tenant:tenant-1:deduct_lock", + timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS, + blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS, + ) + redis_lock.acquire.assert_called_once_with(blocking=True) + redis_lock.release.assert_called_once_with() + get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.PAID) + + +@pytest.mark.parametrize( + "deduct_method", + [ + CreditPoolService.check_and_deduct_credits, + CreditPoolService.deduct_credits_capped, + ], +) +def test_non_positive_credit_request_skips_tenant_redis_lock(deduct_method) -> None: + with patch("services.credit_pool_service.redis_client.lock") as lock: + result = deduct_method(tenant_id="tenant-1", credits_required=0) + + assert result == 0 + lock.assert_not_called() + + +def test_check_and_deduct_credits_wraps_redis_lock_errors_without_querying_db() -> None: + session_maker = MagicMock() + + with ( + patch("services.credit_pool_service.redis_client.lock", side_effect=RuntimeError("redis unavailable")), + patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=1) + + session_maker.begin.assert_not_called() + + +def test_deduct_credits_capped_ignores_release_errors_after_successful_deduction() -> None: + session = MagicMock() + session_maker = _make_session_maker(session) + pool = SimpleNamespace(remaining_credits=3, quota_used=7) + redis_lock = _make_redis_lock() + redis_lock.release.side_effect = RuntimeError("release failed") + + with ( + patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock), + patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker), + patch.object(CreditPoolService, "_get_locked_pool", return_value=pool), + ): + result = CreditPoolService.deduct_credits_capped(tenant_id="tenant-1", credits_required=2) + + assert result == 2 + assert pool.quota_used == 9 + redis_lock.release.assert_called_once_with() diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 4c4abbbb8ec..d9d81d66566 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -1,4 +1,5 @@ import dataclasses +import logging from datetime import datetime, timedelta from unittest.mock import MagicMock @@ -672,7 +673,7 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, mock_session_f assert "WorkflowRun not found" in str(excinfo.value) -def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factory): +def test_enqueue_resume_app_not_found(mocker, mock_session_factory, caplog): session_factory, session = mock_session_factory service = HumanInputService(session_factory) @@ -687,10 +688,10 @@ def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factor ) session.execute.return_value.scalar_one_or_none.return_value = None - logger_spy = mocker.patch("services.human_input_service.logger") - service.enqueue_resume("workflow-run-id") - logger_spy.error.assert_called_once() + with caplog.at_level(logging.ERROR, logger="services.human_input_service"): + service.enqueue_resume("workflow-run-id") + assert any(r.levelno >= logging.ERROR for r in caplog.records) def test_is_globally_expired_zero_timeout( diff --git a/api/tests/unit_tests/services/test_operation_service.py b/api/tests/unit_tests/services/test_operation_service.py index e43a7fa649e..dffded658eb 100644 --- a/api/tests/unit_tests/services/test_operation_service.py +++ b/api/tests/unit_tests/services/test_operation_service.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest -from services.operation_service import OperationService +from services.operation_service import OPERATION_REQUEST_TIMEOUT, OperationService class TestOperationService: @@ -44,6 +44,7 @@ class TestOperationService: assert kwargs["json"] == json_data assert kwargs["headers"]["Billing-Api-Secret-Key"] == "s3cr3t" assert kwargs["headers"]["Content-Type"] == "application/json" + assert kwargs["timeout"] == OPERATION_REQUEST_TIMEOUT @patch("httpx.request") def test_should_propagate_httpx_error_when__send_request_raises( diff --git a/api/tests/unit_tests/services/test_summary_index_service.py b/api/tests/unit_tests/services/test_summary_index_service.py index e17d4134ace..cef11c0038d 100644 --- a/api/tests/unit_tests/services/test_summary_index_service.py +++ b/api/tests/unit_tests/services/test_summary_index_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import sys from dataclasses import dataclass from datetime import UTC, datetime @@ -532,7 +533,10 @@ def test_vectorize_summary_error_handler_tries_chunk_id_lookup_and_can_warn_not_ error_session.commit.assert_not_called() -def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_summary_record_error_warns_when_missing( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: dataset = _dataset() segment = _segment() @@ -544,14 +548,15 @@ def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.Monk SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))), ) - logger_mock = MagicMock() - monkeypatch.setattr(summary_module, "logger", logger_mock) - - SummaryIndexService.update_summary_record_error(segment, dataset, "err") - logger_mock.warning.assert_called_once() + with caplog.at_level(logging.WARNING, logger="services.summary_index_service"): + SummaryIndexService.update_summary_record_error(segment, dataset, "err") + assert any(r.levelno >= logging.WARNING for r in caplog.records) -def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(monkeypatch: pytest.MonkeyPatch) -> None: +def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: dataset = _dataset() segment = _segment() @@ -567,12 +572,10 @@ def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(mo monkeypatch.setattr(SummaryIndexService, "generate_summary_for_segment", MagicMock(return_value=("sum", usage))) monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None)) - logger_mock = MagicMock() - monkeypatch.setattr(summary_module, "logger", logger_mock) - - result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True}) - assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED} - logger_mock.info.assert_called() + with caplog.at_level(logging.INFO, logger="services.summary_index_service"): + result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True}) + assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED} + assert any(r.levelno >= logging.INFO for r in caplog.records) def test_generate_summaries_for_document_skip_conditions(monkeypatch: pytest.MonkeyPatch) -> None: @@ -759,6 +762,7 @@ def test_enable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.Mon def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vectorize_error( monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: dataset = _dataset() summary1 = _summary_record(summary_content="sum", node_id="n1") @@ -786,12 +790,11 @@ def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vect SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))), ) - logger_mock = MagicMock() - monkeypatch.setattr(summary_module, "logger", logger_mock) monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(side_effect=RuntimeError("boom"))) - SummaryIndexService.enable_summaries_for_segments(dataset) - logger_mock.exception.assert_called_once() + with caplog.at_level(logging.ERROR, logger="services.summary_index_service"): + SummaryIndexService.enable_summaries_for_segments(dataset) + assert any(r.levelno >= logging.ERROR for r in caplog.records) session.commit.assert_called_once() @@ -859,7 +862,10 @@ def test_update_summary_for_segment_empty_content_deletes_existing(monkeypatch: session.commit.assert_called_once() -def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_summary_for_segment_empty_content_delete_vector_warns( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: dataset = _dataset() segment = _segment() record = _summary_record(summary_content="old", node_id="n1") @@ -875,11 +881,10 @@ def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatc vector_instance = MagicMock() vector_instance.delete_by_ids.side_effect = RuntimeError("boom") monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance)) - logger_mock = MagicMock() - monkeypatch.setattr(summary_module, "logger", logger_mock) - assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None - logger_mock.warning.assert_called() + with caplog.at_level(logging.WARNING, logger="services.summary_index_service"): + assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None + assert any(r.levelno >= logging.WARNING for r in caplog.records) def test_update_summary_for_segment_empty_content_no_record_noop(monkeypatch: pytest.MonkeyPatch) -> None: @@ -923,7 +928,10 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch: session.commit.assert_called() -def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_summary_for_segment_existing_vector_delete_warns( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: dataset = _dataset() segment = _segment() record = _summary_record(summary_content="old", node_id="n1") @@ -940,11 +948,10 @@ def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: py vector_instance.delete_by_ids.side_effect = RuntimeError("boom") monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance)) monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None)) - logger_mock = MagicMock() - monkeypatch.setattr(summary_module, "logger", logger_mock) - SummaryIndexService.update_summary_for_segment(segment, dataset, "new") - logger_mock.warning.assert_called() + with caplog.at_level(logging.WARNING, logger="services.summary_index_service"): + SummaryIndexService.update_summary_for_segment(segment, dataset, "new") + assert any(r.levelno >= logging.WARNING for r in caplog.records) def test_update_summary_for_segment_existing_vectorize_failure_returns_error_record( diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index a47d946bab0..0a4452cf478 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -253,7 +253,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached mock_session: MagicMock, provider_id: TriggerProviderID, provider_controller: MagicMock, - caplog, + caplog: pytest.LogCaptureFixture, ) -> None: # Arrange _patch_redis_lock(mocker) @@ -274,7 +274,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached properties={}, credentials={}, ) - assert sum(1 for r in caplog.records if r.levelno >= logging.ERROR) == 1 + assert any(r.levelno >= logging.ERROR for r in caplog.records) def test_add_trigger_subscription_should_raise_error_when_name_exists( diff --git a/api/tests/unit_tests/services/test_vector_service.py b/api/tests/unit_tests/services/test_vector_service.py index e6cc59144b3..a5873870103 100644 --- a/api/tests/unit_tests/services/test_vector_service.py +++ b/api/tests/unit_tests/services/test_vector_service.py @@ -269,7 +269,8 @@ def test_create_segments_vector_parent_child_uses_default_embedding_model_when_p def test_create_segments_vector_parent_child_missing_document_logs_warning_and_continues( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: dataset = _make_dataset(doc_form=vector_service_module.IndexStructureType.PARENT_CHILD_INDEX) segment = _make_segment() @@ -290,7 +291,7 @@ def test_create_segments_vector_parent_child_missing_document_logs_warning_and_c VectorService.create_segments_vector( None, [segment], dataset, vector_service_module.IndexStructureType.PARENT_CHILD_INDEX ) - assert "Expected DatasetDocument record to exist, but none was found" in caplog.text + assert any(r.levelno >= logging.WARNING for r in caplog.records) index_processor.load.assert_not_called() @@ -614,7 +615,8 @@ def test_update_multimodel_vector_commits_when_no_upload_files_found(monkeypatch def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_upload_files( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True) segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}]) @@ -631,8 +633,7 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up with caplog.at_level(logging.WARNING, logger="services.vector_service"): VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1", "missing"], dataset=dataset) - - assert "Upload file not found for attachment_id" in caplog.text + assert any(r.levelno >= logging.WARNING for r in caplog.records) db_mock.session.add_all.assert_called_once() bindings = db_mock.session.add_all.call_args.args[0] assert len(bindings) == 1 @@ -671,7 +672,8 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops def test_update_multimodel_vector_rolls_back_and_reraises_on_error( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True) segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}]) @@ -691,7 +693,5 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error( with pytest.raises(RuntimeError, match="boom"): VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset) - exception_records = [r for r in caplog.records if r.levelname == "ERROR"] - assert len(exception_records) == 1 - assert "Failed to update multimodal vector for segment" in exception_records[0].getMessage() + assert any(r.levelno >= logging.ERROR for r in caplog.records) db_mock.session.rollback.assert_called_once() diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py index 491dd948427..e3a8e282e9c 100644 --- a/api/tests/unit_tests/services/test_webhook_service_additional.py +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -31,21 +32,21 @@ class TestWebhookServiceExtractionFallbacks: self, flask_app: Flask, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: - warning_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "warning", warning_mock) webhook_trigger = MagicMock() - with flask_app.test_request_context( - "/webhook", - method="POST", - headers={"Content-Type": "application/vnd.custom"}, - data="plain content", - ): - result = WebhookService.extract_webhook_data(webhook_trigger) + with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"): + with flask_app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/vnd.custom"}, + data="plain content", + ): + result = WebhookService.extract_webhook_data(webhook_trigger) - assert result["body"] == {"raw": "plain content"} - warning_mock.assert_called_once() + assert result["body"] == {"raw": "plain content"} + assert any(r.levelno >= logging.WARNING for r in caplog.records) def test_extract_webhook_data_should_raise_for_request_too_large( self, @@ -171,14 +172,13 @@ class TestWebhookServiceValidationAndConversion: def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type( self, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: - warning_mock = MagicMock() - monkeypatch.setattr(service_module.logger, "warning", warning_mock) + with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"): + result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type") - result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type") - - assert result == {"x": 1} - warning_mock.assert_called_once() + assert result == {"x": 1} + assert any(r.levelno >= logging.WARNING for r in caplog.records) def test_validate_and_convert_value_should_wrap_conversion_errors(self) -> None: with pytest.raises(ValueError, match="validation failed"): diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index eafbabe1f9b..dc3a6a31205 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -16,12 +16,14 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.task_entities import StreamEvent from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from core.workflow.human_input_policy import FormDisposition, HumanInputSurface from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource from graphon.nodes.human_input.enums import ValueSourceType from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole +from models.human_input import RecipientType from models.model import AppMode from models.workflow import WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot @@ -763,7 +765,11 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) resumption_context = _build_resumption_context("task-ctx") monkeypatch.setattr( - service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + service_module, + "load_form_dispositions_by_form_id", + lambda form_ids, session=None, surface=None: { + "form-1": FormDisposition(form_token="wtok", approval_channels=[]) + }, ) session_maker = _SessionMaker( SimpleNamespace( @@ -803,12 +809,99 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) +def _build_recipient_snapshot_events(recipients: Sequence[Any]) -> list[Mapping[str, Any]]: + """Drive the reconnect snapshot pause path for the OPENAPI surface. + + Lets the real disposition loader run against a fake session whose ``scalars`` + yields the given recipients, so the reconnect path derives the same token and + approval channels as the live path for the same recipient set. + """ + 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, tzinfo=UTC) + session_maker = _SessionMaker( + SimpleNamespace( + execute=lambda _stmt: [("form-1", expiration_time, '{"display_in_ui": true}')], + scalars=lambda _stmt: list(recipients), + ) + ) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=expiration_time, + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + ) + ], + ) + + return _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=cast(sessionmaker[Session], session_maker), + human_input_surface=HumanInputSurface.OPENAPI, + ) + + +def test_reconnect_pause_without_web_app_recipient_emits_approval_channels() -> None: + events = _build_recipient_snapshot_events( + recipients=[ + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"), + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"), + ], + ) + + human_input_event = events[-2] + assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED + assert human_input_event["data"]["form_token"] is None + assert human_input_event["data"]["approval_channels"] == ["console", "email"] + + pause_data = events[-1]["data"] + assert pause_data["reasons"][0]["form_token"] is None + assert pause_data["reasons"][0]["approval_channels"] == ["console", "email"] + + +def test_reconnect_pause_with_web_app_recipient_sets_token_and_channels() -> None: + events = _build_recipient_snapshot_events( + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-app-token", + ), + SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"), + ], + ) + + human_input_event = events[-2] + assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED + assert human_input_event["data"]["form_token"] == "web-app-token" + assert human_input_event["data"]["approval_channels"] == ["console"] + + pause_data = events[-1]["data"] + assert pause_data["reasons"][0]["form_token"] == "web-app-token" + assert pause_data["reasons"][0]["approval_channels"] == ["console"] + + def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None: workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"]) monkeypatch.setattr( - service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + service_module, + "load_form_dispositions_by_form_id", + lambda form_ids, session=None, surface=None: { + "form-1": FormDisposition(form_token="wtok", approval_channels=[]) + }, ) session_maker = _SessionMaker( SimpleNamespace( @@ -886,7 +979,11 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1")) ) monkeypatch.setattr( - service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + service_module, + "load_form_dispositions_by_form_id", + lambda form_ids, session=None, surface=None: { + "form-1": FormDisposition(form_token="wtok", approval_channels=[]) + }, ) session = SimpleNamespace( diff --git a/cli/scripts/generate-command-tree.test.ts b/cli/scripts/generate-command-tree.test.ts index cf25ddcf71a..c811de8b4fe 100644 --- a/cli/scripts/generate-command-tree.test.ts +++ b/cli/scripts/generate-command-tree.test.ts @@ -137,6 +137,17 @@ export const commandTree: CommandTree = { const verIdx = out.indexOf('Version') expect(authIdx).toBeLessThan(verIdx) }) + + it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => { + const entries: CommandEntry[] = [ + { tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' }, + { tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' }, + ] + const out = formatModule(entries, buildTree(entries)) + expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`) + expect(out).toContain(`app: { command: ExportApp, subcommands: {} },`) + expect(out).not.toContain(`'app':`) + }) }) function makeFixture(): string { diff --git a/cli/scripts/generate-command-tree.ts b/cli/scripts/generate-command-tree.ts index 769490df834..9f3357bd6ea 100644 --- a/cli/scripts/generate-command-tree.ts +++ b/cli/scripts/generate-command-tree.ts @@ -141,13 +141,24 @@ function emitNode(node: TreeNode, indent: string): string { return parts.join('\n') } +function needsQuoting(key: string): boolean { + // A bare object key must be a valid JS identifier: the start class excludes digits + // (letter/_/$ only), so a leading digit fails the match and the key gets quoted. + return !/^[A-Z_$][\w$]*$/i.test(key) +} + +function emitKey(key: string): string { + return needsQuoting(key) ? `'${key}'` : key +} + function emitEntry(key: string, node: TreeNode, indent: string): string { + const k = emitKey(key) const isLeaf = node.subcommands.size === 0 && node.command !== undefined if (isLeaf) - return `${indent}${key}: { command: ${node.command}, subcommands: {} },` + return `${indent}${k}: { command: ${node.command}, subcommands: {} },` return [ - `${indent}${key}: {`, + `${indent}${k}: {`, emitNode(node, indent), `${indent}},`, ].join('\n') diff --git a/cli/src/api/app-meta.ts b/cli/src/api/app-meta.ts index c2ca9a5aa9b..03ae59bd392 100644 --- a/cli/src/api/app-meta.ts +++ b/cli/src/api/app-meta.ts @@ -1,17 +1,17 @@ -import type { AppsClient } from './apps' +import type { AppReader } from './app-reader' import type { AppInfoCache } from '@/cache/app-info' import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta' import { covers, fromDescribe, mergeMeta } from '@/types/app-meta' export type AppMetaClientOptions = { - readonly apps: AppsClient + readonly apps: AppReader readonly host: string readonly cache?: AppInfoCache readonly now?: () => Date } export class AppMetaClient { - private readonly apps: AppsClient + private readonly apps: AppReader private readonly host: string private readonly cache: AppInfoCache | undefined private readonly now: () => Date diff --git a/cli/src/api/app-reader.test.ts b/cli/src/api/app-reader.test.ts new file mode 100644 index 00000000000..ff584c85c49 --- /dev/null +++ b/cli/src/api/app-reader.test.ts @@ -0,0 +1,30 @@ +import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' +import { describe, expect, it } from 'vitest' +import { selectAppReader, SubjectKind, subjectOf } from './app-reader' +import { AppsClient } from './apps' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient + +function ctx(external: boolean): ActiveContext { + return { + host: 'h', + email: 'e', + ctx: { + account: { id: 'a', email: 'e', name: 'n' }, + external_subject: external ? { email: 'e', issuer: 'i' } : undefined, + }, + } +} + +describe('selectAppReader', () => { + it('account login → AppsClient', () => { + expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient) + expect(subjectOf(ctx(false))).toBe(SubjectKind.Account) + }) + it('external_subject present → PermittedExternalAppsClient', () => { + expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient) + expect(subjectOf(ctx(true))).toBe(SubjectKind.External) + }) +}) diff --git a/cli/src/api/app-reader.ts b/cli/src/api/app-reader.ts new file mode 100644 index 00000000000..fe41e35bfe9 --- /dev/null +++ b/cli/src/api/app-reader.ts @@ -0,0 +1,35 @@ +import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { ListQuery } from './apps' +import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' +import { AppsClient } from './apps' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +export type AppReader = { + list: (q: ListQuery) => Promise + describe: (appId: string, fields?: readonly string[]) => Promise +} + +// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface. +export const SubjectKind = { + Account: 'account', + External: 'external', +} as const + +export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind] + +export function subjectOf(active: ActiveContext): SubjectKindValue { + return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account +} + +type AppReaderFactory = (http: HttpClient) => AppReader + +// Maps each auth subject to the app reader for its surface. +const APP_READER_BY_SUBJECT: Readonly> = { + [SubjectKind.Account]: http => new AppsClient(http), + [SubjectKind.External]: http => new PermittedExternalAppsClient(http), +} + +export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader { + return APP_READER_BY_SUBJECT[subjectOf(active)](http) +} diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts index 68e7bcc86a3..861f60feb26 100644 --- a/cli/src/api/apps.test.ts +++ b/cli/src/api/apps.test.ts @@ -36,7 +36,6 @@ describe('AppsClient.list', () => { // Optional filters are omitted entirely when not supplied. expect(q.has('mode')).toBe(false) expect(q.has('name')).toBe(false) - expect(q.has('tag')).toBe(false) }) it('forwards explicit pagination and filters', async () => { @@ -48,7 +47,6 @@ describe('AppsClient.list', () => { limit: 50, mode: 'chat', name: 'support bot', - tag: 'prod', }) const q = queryOf(stub.captured.url) @@ -56,18 +54,16 @@ describe('AppsClient.list', () => { expect(q.get('limit')).toBe('50') expect(q.get('mode')).toBe('chat') expect(q.get('name')).toBe('support bot') - expect(q.get('tag')).toBe('prod') }) it('treats empty-string filters as absent (not blank query params)', async () => { stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) - await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' }) + await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' }) const q = queryOf(stub.captured.url) expect(q.has('mode')).toBe(false) expect(q.has('name')).toBe(false) - expect(q.has('tag')).toBe(false) }) it('propagates server 403 as a classified BaseError', async () => { diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index ea0e41c252f..01b18d9a9da 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,4 +1,5 @@ import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { AppReader } from './app-reader' import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' import { createOpenApiClient } from '@/http/orpc' @@ -9,10 +10,14 @@ export type ListQuery = { readonly limit?: number readonly mode?: AppMode | '' readonly name?: string - readonly tag?: string } -export class AppsClient { +// An absent or empty mode filter means "any mode" — collapse both to undefined for the query. +export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined { + return mode !== undefined && mode !== '' ? mode : undefined +} + +export class AppsClient implements AppReader { private readonly orpc: OpenApiClient constructor(http: HttpClient) { @@ -25,9 +30,8 @@ export class AppsClient { workspace_id: q.workspaceId, page: q.page ?? 1, limit: q.limit ?? 20, - mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined, + mode: normalizeMode(q.mode), name: q.name !== undefined && q.name !== '' ? q.name : undefined, - tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined, }, }) } diff --git a/cli/src/api/permitted-external-apps.test.ts b/cli/src/api/permitted-external-apps.test.ts new file mode 100644 index 00000000000..f6fa38cb3eb --- /dev/null +++ b/cli/src/api/permitted-external-apps.test.ts @@ -0,0 +1,27 @@ +import type { HttpClient } from '@/http/types' +import { describe, expect, it, vi } from 'vitest' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +function fakeHttp() { + return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient +} + +type WithOrpc = { orpc: unknown } + +describe('PermittedExternalAppsClient', () => { + it('list calls permittedExternalApps.get with paging/filter query', async () => { + const c = new PermittedExternalAppsClient(fakeHttp()) + const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] }) + ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } } + await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' }) + expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } }) + }) + + it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => { + const c = new PermittedExternalAppsClient(fakeHttp()) + const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null }) + ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } } + await c.describe('app-1', ['info']) + expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } }) + }) +}) diff --git a/cli/src/api/permitted-external-apps.ts b/cli/src/api/permitted-external-apps.ts new file mode 100644 index 00000000000..497c398d0ba --- /dev/null +++ b/cli/src/api/permitted-external-apps.ts @@ -0,0 +1,34 @@ +import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { AppReader } from './app-reader' +import type { ListQuery } from './apps' +import type { OpenApiClient } from '@/http/orpc' +import type { HttpClient } from '@/http/types' +import { createOpenApiClient } from '@/http/orpc' +import { normalizeMode } from './apps' + +export class PermittedExternalAppsClient implements AppReader { + private readonly orpc: OpenApiClient + + constructor(http: HttpClient) { + this.orpc = createOpenApiClient(http) + } + + // workspaceId is ignored: the external grant is not workspace-scoped. + async list(q: ListQuery): Promise { + return this.orpc.permittedExternalApps.get({ + query: { + page: q.page ?? 1, + limit: q.limit ?? 20, + mode: normalizeMode(q.mode), + name: q.name !== undefined && q.name !== '' ? q.name : undefined, + }, + }) + } + + async describe(appId: string, fields?: readonly string[]): Promise { + return this.orpc.permittedExternalApps.byAppId.describe.get({ + params: { app_id: appId }, + query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined }, + }) + } +} diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index 50ac1c67d35..ae6f15f249d 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta { name: 'Greeter', description: '', mode: 'chat', - author: 'tester', - tags: [], updated_at: undefined, service_api_enabled: false, is_agent: false, diff --git a/cli/src/commands/agent-guides.test.ts b/cli/src/commands/agent-guides.test.ts index e1bcb0925aa..3ad18f540f1 100644 --- a/cli/src/commands/agent-guides.test.ts +++ b/cli/src/commands/agent-guides.test.ts @@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command' import { describe, expect, it } from 'vitest' import Login from '@/commands/auth/login/index' import DescribeApp from '@/commands/describe/app/index' +import ExportStudioApp from '@/commands/export/studio-app/index' import GetApp from '@/commands/get/app/index' +import ImportStudioApp from '@/commands/import/studio-app/index' import ResumeApp from '@/commands/resume/app/index' import RunApp from '@/commands/run/app/index' @@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray = [ ['resume app', ResumeApp], ['describe app', DescribeApp], ['get app', GetApp], + ['export studio-app', ExportStudioApp], + ['import studio-app', ImportStudioApp], ['auth login', Login], ] diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index fb510ef1af1..0b64ac8b14e 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -146,6 +146,43 @@ describe('runDevicesRevoke', () => { expect(saved?.hosts[mock.url]).toBeUndefined() }) + it('TTY without --yes: prompts and aborts on decline (no revoke)', async () => { + const base = bufferStreams('n\n') + const io = { ...base, isErrTTY: true } + const store = new MemStore() + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') + const http = testHttpClient(mock.url, 'dfoa_test') + + await expect(runDevicesRevoke({ io, reg, active, store, http, all: true })) + .rejects + .toThrow(/aborted by user/) + expect(base.errBuf()).toContain('Revoke 2 session(s)? [y/N]') + expect(base.outBuf()).not.toContain('Revoked') + }) + + it('TTY without --yes: proceeds on accept', async () => { + const base = bufferStreams('y\n') + const io = { ...base, isErrTTY: true } + const store = new MemStore() + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') + const http = testHttpClient(mock.url, 'dfoa_test') + + await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false }) + expect(base.outBuf()).toContain('Revoked 1 session(s)') + }) + + it('TTY with --yes: skips prompt entirely', async () => { + const base = bufferStreams() + const io = { ...base, isErrTTY: true } + const store = new MemStore() + const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1') + const http = testHttpClient(mock.url, 'dfoa_test') + + await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false, yes: true }) + expect(base.errBuf()).not.toContain('[y/N]') + expect(base.outBuf()).toContain('Revoked 1 session(s)') + }) + it('no target + no --all: throws UsageMissingArg', async () => { const io = bufferStreams() const store = new MemStore() diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 15f17e0a3e3..b328e59f93f 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -8,6 +8,7 @@ import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { promptConfirm } from '@/sys/io/prompt' import { runWithSpinner } from '@/sys/io/spinner' export type DevicesListOptions = { @@ -96,6 +97,17 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise { diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts index 07ec3ad7720..f11e3ee4b93 100644 --- a/cli/src/commands/delete/member/run.ts +++ b/cli/src/commands/delete/member/run.ts @@ -1,11 +1,11 @@ import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' -import * as readline from 'node:readline' import { MembersClient } from '@/api/members' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' +import { promptConfirm } from '@/sys/io/prompt' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' @@ -76,15 +76,3 @@ export async function runDeleteMember( workspaceId: wsId, } } - -async function promptConfirm(io: IOStreams, message: string): Promise { - io.err.write(message) - const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false }) - try { - const line: string = await new Promise(resolve => rl.once('line', resolve)) - return line.trim().toLowerCase() === 'y' - } - finally { - rl.close() - } -} diff --git a/cli/src/commands/describe/app/handlers.ts b/cli/src/commands/describe/app/handlers.ts index 6b934c87148..133260e6509 100644 --- a/cli/src/commands/describe/app/handlers.ts +++ b/cli/src/commands/describe/app/handlers.ts @@ -1,4 +1,4 @@ -import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen' +import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen' import type { AppMeta } from '@/types/app-meta' export const APP_DESCRIBE_MODE_KEY = 'app-describe' @@ -28,10 +28,8 @@ export class AppDescribeOutput { ['Name', info.name], ['ID', info.id], ['Mode', info.mode], - ['Author', info.author ?? ''], ['Updated', info.updated_at ?? ''], ['Service API', info.service_api_enabled ? 'true' : 'false'], - ['Tags', joinTags(info.tags ?? [])], ] if (info.description !== '' && info.description !== undefined) rows.push(['Description', info.description ?? '']) @@ -55,12 +53,6 @@ export class AppDescribeOutput { } } -function joinTags(tags: readonly TagItem[]): string { - if (tags.length === 0) - return '' - return tags.map(t => t.name).join(',') -} - function alignedRows(rows: readonly [string, string][]): string[] { const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0) return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`) diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 4ed7cedc7a5..96dfae5acb5 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { formatted, stringifyOutput } from '@/framework/output' import { ENV_CACHE_DIR } from '@/store/dir' @@ -34,6 +34,7 @@ describe('runDescribeApp', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + vi.restoreAllMocks() if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] else @@ -60,8 +61,6 @@ describe('runDescribeApp', () => { expect(out).toContain('Mode:') expect(out).toContain('chat') expect(out).toContain('Service API:') - expect(out).toContain('Tags:') - expect(out).toContain('demo') expect(out).toContain('Description:') expect(out).toContain('Parameters:') }) @@ -115,4 +114,13 @@ describe('runDescribeApp', () => { }, )).rejects.toThrow() }) + + it('external login resolves describe via the permitted-external route', async () => { + const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const out = await runDescribeApp( + { appId: 'app-1' }, + { active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url }, + ) + expect(out.payload.info?.id).toBe('app-1') + }) }) diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index 8af2a5dc441..0d5eea784d4 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' -import { AppsClient } from '@/api/apps' +import { selectAppReader } from '@/api/app-reader' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta' @@ -26,7 +26,7 @@ export type DescribeAppDeps = { } export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const io = deps.io ?? nullStreams() const result = await runWithSpinner( diff --git a/cli/src/commands/export/studio-app/guide.ts b/cli/src/commands/export/studio-app/guide.ts new file mode 100644 index 00000000000..d41b502b191 --- /dev/null +++ b/cli/src/commands/export/studio-app/guide.ts @@ -0,0 +1,12 @@ +export const agentGuide = ` +WHEN TO USE + A studio app is what you build and edit in Studio on the web console, + inside a workspace — the app's source definition, not the published app + that 'run app' invokes. Export pulls that definition as YAML to back it + up, diff it, or recreate the app elsewhere with 'import studio-app'. To + run or inspect an app instead, use the 'app' noun. + +ERROR RECOVERY + app not found (404) difyctl get app + not logged in (exit 4) difyctl auth login +` diff --git a/cli/src/commands/export/app/index.ts b/cli/src/commands/export/studio-app/index.ts similarity index 71% rename from cli/src/commands/export/app/index.ts rename to cli/src/commands/export/studio-app/index.ts index 7afd0234982..69bdcf09aa3 100644 --- a/cli/src/commands/export/app/index.ts +++ b/cli/src/commands/export/studio-app/index.ts @@ -1,16 +1,17 @@ import { DifyCommand } from '@/commands/_shared/dify-command' import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Args, Flags } from '@/framework/flags' +import { agentGuide } from './guide' import { runExportApp } from './run' -export default class ExportApp extends DifyCommand { - static override description = 'Export an app\'s DSL configuration as YAML' +export default class ExportStudioApp extends DifyCommand { + static override description = 'Export a studio app\'s DSL configuration as YAML' static override examples = [ - '<%= config.bin %> export app ', - '<%= config.bin %> export app --output ./my-app.yaml', - '<%= config.bin %> export app --include-secret', - '<%= config.bin %> export app --workflow-id ', + '<%= config.bin %> export studio-app ', + '<%= config.bin %> export studio-app --output ./my-app.yaml', + '<%= config.bin %> export studio-app --include-secret', + '<%= config.bin %> export studio-app --workflow-id ', ] static override args = { @@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand { } async run(argv: string[]) { - const { args, flags } = this.parse(ExportApp, argv) + const { args, flags } = this.parse(ExportStudioApp, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) const result = await runExportApp({ appId: args.id, @@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand { ctx.io.out.write('\n') } } + + override agentGuide(): string { + return agentGuide + } } diff --git a/cli/src/commands/export/app/run.test.ts b/cli/src/commands/export/studio-app/run.test.ts similarity index 100% rename from cli/src/commands/export/app/run.test.ts rename to cli/src/commands/export/studio-app/run.test.ts diff --git a/cli/src/commands/export/app/run.ts b/cli/src/commands/export/studio-app/run.ts similarity index 89% rename from cli/src/commands/export/app/run.ts rename to cli/src/commands/export/studio-app/run.ts index 93abe2f8b46..351a2fc349f 100644 --- a/cli/src/commands/export/app/run.ts +++ b/cli/src/commands/export/studio-app/run.ts @@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): const io = deps.io ?? nullStreams() const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) - // workspace is needed to satisfy the auth pipeline; resolving it here - // mirrors what other commands do even though the export endpoint does not - // take workspace_id as a query parameter (it loads tenant from app). + // workspace is resolved to satisfy the auth pipeline; the export endpoint itself + // takes no workspace_id query parameter (it loads tenant from the app). resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) const client = dslFactory(deps.http) diff --git a/cli/src/commands/get/app/handlers.ts b/cli/src/commands/get/app/handlers.ts index ac7008fa537..9c91057d26d 100644 --- a/cli/src/commands/get/app/handlers.ts +++ b/cli/src/commands/get/app/handlers.ts @@ -1,4 +1,4 @@ -import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen' +import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen' import type { TableCell, TableColumn } from '@/framework/output' export const APP_MODE_KEY = 'app' @@ -7,9 +7,7 @@ export const APP_COLUMNS: readonly TableColumn[] = [ { name: 'NAME', priority: 0 }, { name: 'ID', priority: 0 }, { name: 'MODE', priority: 0 }, - { name: 'TAGS', priority: 0 }, { name: 'UPDATED', priority: 0 }, - { name: 'AUTHOR', priority: 1 }, { name: 'WORKSPACE', priority: 1 }, ] @@ -25,9 +23,7 @@ export class AppRow { this.data.name, this.data.id, this.data.mode, - joinTags(this.data.tags ?? []), this.data.updated_at ?? '', - this.data.created_by_name ?? '', this.data.workspace_name ?? '', ] } @@ -70,7 +66,3 @@ export class AppListOutput { return this.envelope } } - -function joinTags(tags: readonly TagItem[]): string { - return tags.map(t => t.name).join(',') -} diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index 47594813704..ffce31b7c49 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -42,7 +42,6 @@ export default class GetApp extends DifyCommand { 'limit': Flags.string({ description: 'page size [1..200]' }), 'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }), 'name': Flags.string({ description: 'filter by app name (server-side substring)' }), - 'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }), 'http-retry': httpRetryFlag, 'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }), } @@ -59,7 +58,6 @@ export default class GetApp extends DifyCommand { limitRaw: flags.limit, mode: flags.mode as AppMode | undefined, name: flags.name, - tag: flags.tag, format, }, { active: ctx.active, http: ctx.http, io: ctx.io }) return table({ diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index 7c0cc76c009..d517859addd 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -1,8 +1,9 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { stringifyOutput, table } from '@/framework/output' import { AppListOutput } from './handlers.js' import { runGetApp } from './run.js' @@ -25,6 +26,7 @@ describe('runGetApp', () => { }) afterEach(async () => { + vi.restoreAllMocks() await mock.stop() }) @@ -40,13 +42,12 @@ describe('runGetApp', () => { })) } - it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => { + it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => { const out = await render() - expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/) + expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/) expect(out).toContain('Greeter') expect(out).toContain('app-1') expect(out).toContain('chat') - expect(out).toContain('demo') expect(out).toContain('Workflow') expect(out).not.toContain('app-3') }) @@ -56,9 +57,7 @@ describe('runGetApp', () => { 'NAME', 'ID', 'MODE', - 'TAGS', 'UPDATED', - 'AUTHOR', 'WORKSPACE', ]) }) @@ -76,12 +75,6 @@ describe('runGetApp', () => { expect(out).not.toContain('Greeter') }) - it('--tag filters server-side', async () => { - const out = await render({ tag: 'demo' }) - expect(out).toContain('Greeter') - expect(out).not.toContain('Workflow') - }) - it('-A all-workspaces aggregates across workspaces sorted by id', async () => { const out = await render({ allWorkspaces: true }) expect(out).toContain('app-1') @@ -110,10 +103,9 @@ describe('runGetApp', () => { expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2']) }) - it('-o wide includes AUTHOR and WORKSPACE columns', async () => { + it('-o wide includes the WORKSPACE column', async () => { const out = await render({ format: 'wide' }) - expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/) - expect(out).toContain('tester') + expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/) expect(out).toContain('Default') }) @@ -138,4 +130,25 @@ describe('runGetApp', () => { } await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/) }) + + it('external login lists via permitted-external client without workspace', async () => { + const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', updated_at: null, workspace_id: 'w', workspace_name: 'W' }] }) + const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') + vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list) + const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient + const res = await runGetApp({}, { active, http }) + expect(list).toHaveBeenCalled() + const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string } + expect(firstCallArg.workspaceId).toBe('') + expect(res.data).toBeDefined() + }) + + it('--all-workspaces throws UsageInvalidFlag for external logins', async () => { + const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient + await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })) + .rejects + .toThrow(/--all-workspaces is not available for external logins/) + }) }) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 102cf066499..c4a7911e0db 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,9 +1,12 @@ import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { AppReader } from '@/api/app-reader' import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' -import { AppsClient } from '@/api/apps' +import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader' import { WorkspacesClient } from '@/api/workspaces' +import { newError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit' import { getEnv } from '@/sys/index' import { runWithSpinner } from '@/sys/io/spinner' @@ -19,7 +22,6 @@ export type GetAppOptions = { readonly limitRaw?: string readonly mode?: AppMode readonly name?: string - readonly tag?: string readonly format?: string } @@ -28,7 +30,6 @@ export type GetAppDeps = { readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly appsFactory?: (http: HttpClient) => AppsClient readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } @@ -40,10 +41,10 @@ export type GetAppResult = { export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise { const env = deps.envLookup ?? getEnv - const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h)) const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) - const apps = appsFactory(deps.http) + const external = subjectOf(deps.active) === SubjectKind.External + const apps = selectAppReader(deps.active, deps.http) const pageSize = resolveLimit(opts.limitRaw, env) const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps' @@ -53,15 +54,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise< { io, label }, async (): Promise => { if (opts.allWorkspaces === true) { + if (external) + throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins') const ws = wsFactory(deps.http) return runAllWorkspaces(apps, ws, opts, page, pageSize) } if (opts.appId !== undefined && opts.appId !== '') { - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) - const wsName = workspaceNameForId(deps.active, wsId) + const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) + const wsName = external ? '' : workspaceNameForId(deps.active, wsId) const desc = await apps.describe(opts.appId, ['info']) return describeToEnvelope(desc, wsId, wsName) } + if (external) { + return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name }) + } const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) return apps.list({ workspaceId: wsId, @@ -69,7 +75,6 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise< limit: pageSize, mode: opts.mode, name: opts.name, - tag: opts.tag, }) }, ) @@ -102,9 +107,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str name: desc.info.name, description: desc.info.description, mode: desc.info.mode as AppMode, - tags: desc.info.tags, updated_at: desc.info.updated_at, - created_by_name: desc.info.author === '' ? undefined : desc.info.author, workspace_id: wsId, workspace_name: wsName === '' ? undefined : wsName, }], @@ -118,7 +121,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string { } async function runAllWorkspaces( - apps: AppsClient, + apps: AppReader, ws: WorkspacesClient, opts: GetAppOptions, page: number, @@ -139,7 +142,6 @@ async function runAllWorkspaces( limit, mode: opts.mode, name: opts.name, - tag: opts.tag, }) merged.total += env.total merged.data = [...merged.data, ...env.data] diff --git a/cli/src/commands/import/studio-app/guide.ts b/cli/src/commands/import/studio-app/guide.ts new file mode 100644 index 00000000000..66ecdda9aa5 --- /dev/null +++ b/cli/src/commands/import/studio-app/guide.ts @@ -0,0 +1,17 @@ +export const agentGuide = ` +WHEN TO USE + A studio app is what you build and edit in Studio on the web console, + inside a workspace — the app's source definition. Import materialises a + DSL YAML into a new (or existing) studio app; pair it with + 'export studio-app' to move an app between workspaces or instances. To + run or inspect the result, switch to the 'app' noun. + +BEHAVIOUR + A DSL version mismatch is auto-confirmed; no second command needed. + Missing plugin dependencies are listed on stderr — install them before + running the app. + +ERROR RECOVERY + workspace required difyctl get workspace + not logged in (exit 4) difyctl auth login +` diff --git a/cli/src/commands/import/app/index.ts b/cli/src/commands/import/studio-app/index.ts similarity index 80% rename from cli/src/commands/import/app/index.ts rename to cli/src/commands/import/studio-app/index.ts index fddda88f441..6e0b491199b 100644 --- a/cli/src/commands/import/app/index.ts +++ b/cli/src/commands/import/studio-app/index.ts @@ -1,16 +1,17 @@ import { DifyCommand } from '@/commands/_shared/dify-command' import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Flags } from '@/framework/flags' +import { agentGuide } from './guide' import { pluginDependencyLabel, runImportApp } from './run' -export default class ImportApp extends DifyCommand { - static override description = 'Import an app from a DSL YAML file or URL' +export default class ImportStudioApp extends DifyCommand { + static override description = 'Import a studio app from a DSL YAML file or URL' static override examples = [ - '<%= config.bin %> import app --from-file ./app.yaml', - '<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"', - '<%= config.bin %> import app --from-url https://example.com/my-app.yaml', - '<%= config.bin %> import app --from-file ./app.yaml --app-id ', + '<%= config.bin %> import studio-app --from-file ./app.yaml', + '<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"', + '<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml', + '<%= config.bin %> import studio-app --from-file ./app.yaml --app-id ', ] static override flags = { @@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand { } async run(argv: string[]) { - const { flags } = this.parse(ImportApp, argv) + const { flags } = this.parse(ImportStudioApp, argv) if (flags['from-file'] === undefined && flags['from-url'] === undefined) this.error('one of --from-file or --from-url is required', { exit: 1 }) if (flags['from-file'] !== undefined && flags['from-url'] !== undefined) @@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand { ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`) } } + + override agentGuide(): string { + return agentGuide + } } diff --git a/cli/src/commands/import/app/run.test.ts b/cli/src/commands/import/studio-app/run.test.ts similarity index 100% rename from cli/src/commands/import/app/run.test.ts rename to cli/src/commands/import/studio-app/run.test.ts diff --git a/cli/src/commands/import/app/run.ts b/cli/src/commands/import/studio-app/run.ts similarity index 100% rename from cli/src/commands/import/app/run.ts rename to cli/src/commands/import/studio-app/run.ts diff --git a/cli/src/commands/resume/app/guide.ts b/cli/src/commands/resume/app/guide.ts index 03b904f3d5b..1483d40b9d4 100644 --- a/cli/src/commands/resume/app/guide.ts +++ b/cli/src/commands/resume/app/guide.ts @@ -1,14 +1,15 @@ export const agentGuide = ` WHEN TO USE Continue a workflow that paused for human input. run app (or a prior - resume app) exits 2 and prints a JSON object with status "paused", + resume app) exits 0 and prints a JSON object with status "paused", form_token, workflow_run_id and resolved_default_values. Resume with: difyctl resume app --workflow-run-id \\ --inputs '{"name":"Alice"}' -o json LOOP - A resume can pause again (exit 2 with a new form_token). Repeat until - exit 0. Pass --stream to print events live. + A resume can pause again (exit 0 with a new form_token and status + "paused"). Repeat until the output is no longer a pause. Pass --stream + to print events live. ERROR RECOVERY not logged in (exit 4) difyctl auth login diff --git a/cli/src/commands/resume/app/run.test.ts b/cli/src/commands/resume/app/run.test.ts new file mode 100644 index 00000000000..a72b0a93b25 --- /dev/null +++ b/cli/src/commands/resume/app/run.test.ts @@ -0,0 +1,66 @@ +import type { ActiveContext } from '@/auth/hosts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppRunClient } from '@/api/app-run' +import { AppsClient } from '@/api/apps' +import { PermittedExternalAppsClient } from '@/api/permitted-external-apps' +import { bufferStreams } from '@/sys/io/streams' +import { resumeApp } from './run.js' + +const DESCRIBE_RESULT = { + info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', updated_at: null, service_api_enabled: true, is_agent: false }, + parameters: null, + input_schema: null, +} + +const FORM_RESP = { user_actions: [{ id: 'submit' }] } + +function makeExternalActive(): ActiveContext { + return { + host: 'http://localhost', + email: 'sso@x.io', + ctx: { + account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, + external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, + }, + } as unknown as ActiveContext +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('resumeApp pre-flight subject strategy', () => { + it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { + const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT) + const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') + + vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never) + + const io = bufferStreams() + const http = { + baseURL: 'http://localhost', + request: vi.fn().mockImplementation((opts: { path: string }) => { + if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) { + return Promise.resolve(FORM_RESP) + } + // reconnect stream — return an async iterable that ends immediately + const iter: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) } + return Promise.resolve(iter) + }), + } as unknown as import('@/http/types').HttpClient + + try { + await resumeApp( + { appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} }, + { active: makeExternalActive(), http, host: 'http://localhost', io }, + ) + } + catch { + // run may fail after pre-flight due to stream mock; we only check which describe was called + } + + expect(externalSpy).toHaveBeenCalled() + expect(accountSpy).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 81eb4e01f00..1dc2855a118 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' +import { selectAppReader } from '@/api/app-reader' import { AppRunClient } from '@/api/app-run' -import { AppsClient } from '@/api/apps' import { pickStrategy } from '@/commands/run/app/_strategies/index' import { RUN_MODES } from '@/commands/run/app/handlers' +import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags' import { processExit } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' import { FieldInfo } from '@/types/app-meta' @@ -37,45 +38,8 @@ export type ResumeAppDeps = { readonly exit?: (code: number) => never } -const TEXT_FORMATS = new Set(['', 'text']) - -async function resolveInputs( - inputsJson: string | undefined, - inputsFile: string | undefined, - directInputs: Readonly> | undefined, -): Promise> { - if (inputsJson !== undefined && inputsFile !== undefined) - throw new Error('--inputs and --inputs-file are mutually exclusive') - if (inputsJson !== undefined) { - let parsed: unknown - try { - parsed = JSON.parse(inputsJson) - } - catch { - throw new Error('--inputs must be valid JSON') - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new Error('--inputs must be a JSON object') - return parsed as Record - } - if (inputsFile !== undefined) { - const { readFile } = await import('node:fs/promises') - let parsed: unknown - try { - parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } - catch { - throw new Error('--inputs-file must contain valid JSON') - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new Error('--inputs-file must be a JSON object') - return parsed as Record - } - return { ...(directInputs ?? {}) } -} - export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const m = await meta.get(opts.appId, [FieldInfo]) const mode = m.info?.mode ?? RUN_MODES.Workflow diff --git a/cli/src/commands/run/app/_strategies/streaming-structured.ts b/cli/src/commands/run/app/_strategies/streaming-structured.ts index c6f02292528..3ed602a3410 100644 --- a/cli/src/commands/run/app/_strategies/streaming-structured.ts +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -1,16 +1,14 @@ import type { RunContext, RunStrategy } from './index' import type { SseEvent } from '@/http/sse' import { buildRunBody } from '@/api/app-run' -import { chatConversationHint, newAppRunObject, RUN_MODES } from '@/commands/run/app/handlers' +import { CHAT_MODES, chatConversationHint, newAppRunObject, RUN_MODES } from '@/commands/run/app/handlers' import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render' import { collect, HitlPauseError } from '@/commands/run/app/sse-collector' import { formatted, stringifyOutput } from '@/framework/output' import { handle, unhandle } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' import { startSpinner } from '@/sys/io/spinner' -import { extractThinkBlocks, stripThinkBlocks } from '@/sys/io/think-filter' - -const CHAT_MODES: ReadonlySet = new Set([RUN_MODES.Chat, RUN_MODES.AgentChat, RUN_MODES.AdvancedChat]) +import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks } from '@/sys/io/think-filter' async function* captureTaskId( iter: AsyncIterable, @@ -88,6 +86,18 @@ export class StreamingStructuredStrategy implements RunStrategy { processedResp = { ...processedResp, answer: stripThinkBlocks(processedResp.answer) } } } + else if (mode === RUN_MODES.Workflow) { + const data = processedResp.data + if (data !== null && typeof data === 'object' && 'outputs' in data) { + const raw = (data as { outputs: unknown }).outputs + if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { + const { outputs, thinking } = filterThinkInOutputs(raw as Record, ctx.think) + if (ctx.think && thinking !== '') + deps.io.err.write(`${thinking}\n`) + processedResp = { ...processedResp, data: { ...(data as Record), outputs } } + } + } + } const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) }))) diff --git a/cli/src/commands/run/app/guide.ts b/cli/src/commands/run/app/guide.ts index 70433fb4632..66403c0cbf1 100644 --- a/cli/src/commands/run/app/guide.ts +++ b/cli/src/commands/run/app/guide.ts @@ -9,14 +9,14 @@ WORKFLOW difyctl run app --inputs '{"key":"value"}' -o json APP MODES - chat / advanced-chat Conversational. Accepts --conversation to - resume an existing thread. + chat / agent-chat / Conversational. Accept --conversation to + advanced-chat resume an existing thread. agent-chat adds + autonomous tool use. completion Single-turn. Ignores --conversation. workflow Multi-step graph. Pass all input variables as a JSON object via --inputs. - agent-chat Conversational with autonomous tool use. -HITL PAUSE (exit code 2) +HITL PAUSE (exit code 0 — success-with-pending) When a workflow pauses for human input, stdout receives a JSON object with status "paused", form_token, workflow_run_id, and resolved_default_values. Resume with: diff --git a/cli/src/commands/run/app/handlers.ts b/cli/src/commands/run/app/handlers.ts index 3d3d75ec082..9536c4fe2a9 100644 --- a/cli/src/commands/run/app/handlers.ts +++ b/cli/src/commands/run/app/handlers.ts @@ -11,6 +11,12 @@ export const RUN_MODES = { export type RunMode = typeof RUN_MODES[keyof typeof RUN_MODES] +export const CHAT_MODES: ReadonlySet = new Set([ + RUN_MODES.Chat, + RUN_MODES.AgentChat, + RUN_MODES.AdvancedChat, +]) + export type AppRunObject = FormattedPrintable export function newAppRunObject(mode: string, resp: Record): AppRunObject { diff --git a/cli/src/commands/run/app/hitl-render.test.ts b/cli/src/commands/run/app/hitl-render.test.ts new file mode 100644 index 00000000000..92d91575735 --- /dev/null +++ b/cli/src/commands/run/app/hitl-render.test.ts @@ -0,0 +1,68 @@ +import type { HitlPauseData, HitlPausePayload } from './sse-collector' +import { describe, expect, it } from 'vitest' +import { buildHitlExitObject, renderHitlHint } from './hitl-render' + +function payload(overrides: Partial = {}): HitlPausePayload { + return { + event: 'human_input_required', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { + form_id: 'form-1', + node_id: 'node-1', + node_title: 'Approve', + form_content: 'Please approve', + inputs: [], + actions: [], + display_in_ui: false, + form_token: null, + approval_channels: ['email'], + resolved_default_values: {}, + expiration_time: 0, + ...overrides, + }, + } +} + +describe('renderHitlHint — non-resumable form (form_token null)', () => { + it.each<[string[], string]>([ + [['email'], 'form delivered via email — resume only from that channel'], + [['console'], 'form delivered via the console — resume only from that channel'], + [['web_app'], 'form delivered via the web app — resume only from that channel'], + [['console', 'email'], 'form delivered via the console or email — resume only from those channels'], + [[], 'form delivered via another channel — resume only from that channel'], + ])('renders %j as the channel note', (channels, expected) => { + const out = renderHitlHint('app-1', payload({ approval_channels: channels }), false) + expect(out).toBe(`hint: workflow paused — ${expected}\n`) + expect(out).not.toContain('difyctl resume') + }) + + it('falls back to a generic note when approval_channels is absent (older server)', () => { + const p = payload() + delete p.data.approval_channels + const out = renderHitlHint('app-1', p, false) + expect(out).toContain('another channel') + }) +}) + +describe('renderHitlHint — resumable form (form_token present)', () => { + it('renders the resume command and ignores approval_channels', () => { + const out = renderHitlHint('app-1', payload({ form_token: 'tok-123', approval_channels: [] }), false) + expect(out).toContain('difyctl resume app app-1 tok-123 --workflow-run-id run-1') + expect(out).not.toContain('delivered via') + }) +}) + +describe('buildHitlExitObject', () => { + it('carries approval_channels into the JSON exit object', () => { + const obj = buildHitlExitObject('app-1', payload({ approval_channels: ['email'] })) + expect(obj.approval_channels).toEqual(['email']) + expect(obj.form_token).toBeNull() + }) + + it('defaults approval_channels to [] when absent', () => { + const p = payload({ form_token: 'tok' }) + delete p.data.approval_channels + expect(buildHitlExitObject('app-1', p).approval_channels).toEqual([]) + }) +}) diff --git a/cli/src/commands/run/app/hitl-render.ts b/cli/src/commands/run/app/hitl-render.ts index 991c63e1112..6eb251f4b38 100644 --- a/cli/src/commands/run/app/hitl-render.ts +++ b/cli/src/commands/run/app/hitl-render.ts @@ -10,6 +10,7 @@ export type HitlExitObject = { node_id: string node_title: string form_token: string | null + approval_channels: string[] form_content: string inputs: unknown[] actions: unknown[] @@ -29,6 +30,7 @@ export function buildHitlExitObject(appId: string, payload: HitlPausePayload): H node_id: d.node_id, node_title: d.node_title, form_token: d.form_token, + approval_channels: d.approval_channels ?? [], form_content: d.form_content, inputs: d.inputs, actions: d.actions, @@ -92,15 +94,35 @@ export function renderHitlOutput(appId: string, payload: HitlPausePayload, isTex return `${renderHitlExit(obj)}\n` } -const EXTERNAL_CHANNEL_NOTE = 'form delivered via email/external channel — resume only from that channel' +// Server approval-channel labels → human wording for the pause hint. +const APPROVAL_CHANNEL_LABELS: Record = { + email: 'email', + console: 'the console', + web_app: 'the web app', +} + +function describeApprovalChannels(channels: string[]): string { + const labels = channels.map(c => APPROVAL_CHANNEL_LABELS[c] ?? c) + if (labels.length <= 1) + return labels[0] ?? 'another channel' + if (labels.length === 2) + return `${labels[0]} or ${labels[1]}` + return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}` +} + +function externalChannelNote(channels: string[]): string { + const where = channels.length > 1 ? 'those channels' : 'that channel' + return `form delivered via ${describeApprovalChannels(channels)} — resume only from ${where}` +} export function renderHitlHint(appId: string, payload: HitlPausePayload, isErrTTY: boolean): string { const d = payload.data const cs = colorScheme(colorEnabled(isErrTTY)) if (d.form_token === null) { + const note = externalChannelNote(d.approval_channels ?? []) if (!isErrTTY) - return `hint: workflow paused — ${EXTERNAL_CHANNEL_NOTE}\n` - return `${cs.warningIcon()} ${cs.bold('workflow paused')} — ${cs.dim(EXTERNAL_CHANNEL_NOTE)}\n` + return `hint: workflow paused — ${note}\n` + return `${cs.warningIcon()} ${cs.bold('workflow paused')} — ${cs.dim(note)}\n` } const actions = (d.actions ?? []) as { id: string }[] let cmd = `difyctl resume app ${appId} ${d.form_token} --workflow-run-id ${payload.workflow_run_id}` diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 44ea93c542b..815d708d9d8 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -4,6 +4,7 @@ import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Args, Flags } from '@/framework/flags' import { OutputFormat } from '@/framework/output' import { agentGuide } from './guide' +import { CHAT_MODES } from './handlers' import { runApp } from './run' export default class RunApp extends DifyCommand { @@ -30,7 +31,7 @@ export default class RunApp extends DifyCommand { 'inputs': Flags.string({ description: 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.' }), 'inputs-file': Flags.string({ description: 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.' }), 'file': Flags.stringArray({ description: 'Named file input: --file key=@path for a local file or --file key=https://url for a remote URL. Repeatable.', default: [] }), - 'conversation': Flags.string({ description: 'Resume a chat conversation by id (chat/advanced-chat only)' }), + 'conversation': Flags.string({ description: `Resume a chat conversation by id (${[...CHAT_MODES].join('/')} only)` }), 'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }), 'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), 'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }), diff --git a/cli/src/commands/run/app/input-flags.ts b/cli/src/commands/run/app/input-flags.ts new file mode 100644 index 00000000000..b6d296ad7c0 --- /dev/null +++ b/cli/src/commands/run/app/input-flags.ts @@ -0,0 +1,42 @@ +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' + +// Output formats that render the run/resume result as plain text rather than JSON/YAML. +export const TEXT_FORMATS = new Set(['', 'text']) + +// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) / +// direct inputs are mutually exclusive ways to supply the run's variable map. +export async function resolveInputs( + inputsJson: string | undefined, + inputsFile: string | undefined, + directInputs: Readonly> | undefined, +): Promise> { + if (inputsJson !== undefined && inputsFile !== undefined) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' }) + if (inputsJson !== undefined) { + let parsed: unknown + try { + parsed = JSON.parse(inputsJson) + } + catch { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' }) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' }) + return parsed as Record + } + if (inputsFile !== undefined) { + const { readFile } = await import('node:fs/promises') + let parsed: unknown + try { + parsed = JSON.parse(await readFile(inputsFile, 'utf8')) + } + catch { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' }) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' }) + return parsed as Record + } + return { ...(directInputs ?? {}) } +} diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index 4fff2d02873..57b02aeb47d 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -1,11 +1,12 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { resumeApp } from '@/commands/resume/app/run' import { ENV_CACHE_DIR } from '@/store/dir' @@ -165,6 +166,43 @@ describe('runApp', () => { expect(parsed.data.status).toBe('succeeded') }) + it('workflow: strips from outputs by default', async () => { + mock.setScenario('workflow-think') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' } }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.outBuf()).toBe('final answer\n') + expect(io.errBuf()).not.toContain('secret reasoning') + }) + + it('workflow --think: routes to stderr, clean stdout', async () => { + mock.setScenario('workflow-think') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + expect(io.outBuf()).toBe('final answer\n') + expect(io.errBuf()).toContain('secret reasoning') + }) + + it('--stream workflow -o json --think: strips outputs and routes thinking to stderr', async () => { + mock.setScenario('workflow-think') + const io = bufferStreams() + const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json', think: true }, + { active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, + ) + const parsed = JSON.parse(io.outBuf()) as { data: { outputs: { result: string } } } + expect(parsed.data.outputs.result).toBe('final answer') + expect(io.errBuf()).toContain('secret reasoning') + }) + it('stream-error scenario: error event surfaces typed BaseError', async () => { mock.setScenario('stream-error') const io = bufferStreams() @@ -381,4 +419,35 @@ describe('runApp', () => { expect(docInput.transfer_method).toBe('remote_url') expect(docInput.url).toBe('https://example.com/override.pdf') }) + + it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { + const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null } + const externalDescribe = vi.fn().mockResolvedValue(describeResult) + const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') + const { AppsClient } = await import('@/api/apps') + const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') + const io = bufferStreams() + const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient + const activeExt: ActiveContext = { + host: mock.url, + email: 'sso@x.io', + ctx: { + account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, + external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, + }, + } + try { + await runApp( + { appId: 'app-1', message: 'hi' }, + { active: activeExt, http, host: mock.url, io }, + ) + } + catch { + // run may fail due to mocked http; we only care about which describe was called + } + expect(externalSpy).toHaveBeenCalled() + expect(accountSpy).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) }) diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index 8eb767c5dbe..ab468678a72 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' +import { selectAppReader } from '@/api/app-reader' import { AppRunClient } from '@/api/app-run' -import { AppsClient } from '@/api/apps' import { FileUploadClient } from '@/api/file-upload' import { pickStrategy } from '@/commands/run/app/_strategies/index' import { BaseError, HttpClientError } from '@/errors/base' @@ -13,6 +13,7 @@ import { processExit } from '@/sys/index' import { FieldInfo } from '@/types/app-meta' import { resolveFileInputs } from './file-flags.js' import { RUN_MODES } from './handlers.js' +import { resolveInputs, TEXT_FORMATS } from './input-flags.js' export type RunAppOptions = { readonly appId: string @@ -40,45 +41,8 @@ export type RunAppDeps = { readonly exit?: (code: number) => never } -const TEXT_FORMATS = new Set(['', 'text']) - -async function resolveInputs( - inputsJson: string | undefined, - inputsFile: string | undefined, - directInputs: Readonly> | undefined, -): Promise> { - if (inputsJson !== undefined && inputsFile !== undefined) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' }) - if (inputsJson !== undefined) { - let parsed: unknown - try { - parsed = JSON.parse(inputsJson) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' }) - return parsed as Record - } - if (inputsFile !== undefined) { - const { readFile } = await import('node:fs/promises') - let parsed: unknown - try { - parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' }) - return parsed as Record - } - return { ...(directInputs ?? {}) } -} - export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) try { diff --git a/cli/src/commands/run/app/sse-collector.ts b/cli/src/commands/run/app/sse-collector.ts index ba329746e73..043a9690d5f 100644 --- a/cli/src/commands/run/app/sse-collector.ts +++ b/cli/src/commands/run/app/sse-collector.ts @@ -13,6 +13,8 @@ export type HitlPauseData = { actions: unknown[] display_in_ui: boolean form_token: string | null + // Channels where the form can be approved when it is not CLI-resumable, e.g. ['email']. + approval_channels?: string[] resolved_default_values: Record expiration_time: number } diff --git a/cli/src/commands/run/app/stream-handlers.test.ts b/cli/src/commands/run/app/stream-handlers.test.ts index 885b059abf2..bab45144a01 100644 --- a/cli/src/commands/run/app/stream-handlers.test.ts +++ b/cli/src/commands/run/app/stream-handlers.test.ts @@ -74,6 +74,37 @@ describe('streamPrinterFor — workflow', () => { }) }) +describe('streamPrinterFor — workflow think filtering', () => { + it('think: false (default) strips from string outputs, nothing to stderr', () => { + const sp = streamPrinterFor('workflow') + const cap = captures() + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { text: 'hidden\nresult' } } })) + sp.onEnd(cap.out, cap.err) + const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } + expect(parsed.text).toBe('result') + expect(cap.errBuf()).toBe('') + }) + + it('think: true strips from string outputs and routes thinking to stderr', () => { + const sp = streamPrinterFor('workflow', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: { text: 'reasoning\nresult' } } })) + sp.onEnd(cap.out, cap.err) + const parsed = JSON.parse(cap.outBuf().trim()) as { text: string } + expect(parsed.text).toBe('result') + expect(cap.errBuf()).toContain('') + expect(cap.errBuf()).toContain('reasoning') + }) + + it('array outputs pass through unchanged (not reshaped into an object)', () => { + const sp = streamPrinterFor('workflow', true) + const cap = captures() + sp.onEvent(cap.out, cap.err, ev('workflow_finished', { data: { outputs: ['a', 'b'] } })) + sp.onEnd(cap.out, cap.err) + expect(cap.outBuf().trim()).toBe('["a","b"]') + }) +}) + describe('streamPrinterFor — unknown mode', () => { it('throws', () => { expect(() => streamPrinterFor('whatever')).toThrow() diff --git a/cli/src/commands/run/app/stream-handlers.ts b/cli/src/commands/run/app/stream-handlers.ts index 355813d82b9..1955d5996bf 100644 --- a/cli/src/commands/run/app/stream-handlers.ts +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -4,7 +4,7 @@ import type { SseEvent } from '@/http/sse' import { newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { colorEnabled, colorScheme } from '@/sys/io/color' -import { ThinkChunkFilter } from '@/sys/io/think-filter' +import { filterThinkInOutputs, ThinkChunkFilter } from '@/sys/io/think-filter' import { RUN_MODES } from './handlers' import { HitlPauseError } from './sse-collector' @@ -106,6 +106,11 @@ class CompletionStreamPrinter implements StreamPrinter { class WorkflowStreamPrinter implements StreamPrinter { private final: Record | undefined + private readonly think: boolean + constructor(think: boolean) { + this.think = think + } + onEvent(_out: NodeJS.WritableStream, errOut: NodeJS.WritableStream, ev: SseEvent): void { if (handleCommonEvents(ev)) return @@ -132,12 +137,20 @@ class WorkflowStreamPrinter implements StreamPrinter { } } - onEnd(out: NodeJS.WritableStream): void { + onEnd(out: NodeJS.WritableStream, errOut: NodeJS.WritableStream): void { if (this.final === undefined) return const data = this.final.data if (data !== null && typeof data === 'object' && 'outputs' in data) { - out.write(`${JSON.stringify((data as { outputs: unknown }).outputs)}\n`) + const raw = (data as { outputs: unknown }).outputs + if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { + const { outputs, thinking } = filterThinkInOutputs(raw as Record, this.think) + if (this.think && thinking !== '') + errOut.write(`${thinking}\n`) + out.write(`${JSON.stringify(outputs)}\n`) + return + } + out.write(`${JSON.stringify(raw)}\n`) return } out.write(`${JSON.stringify(this.final)}\n`) @@ -149,7 +162,7 @@ const FACTORIES: Record StreamPrinte [RUN_MODES.AdvancedChat]: (think, isTTY) => new ChatStreamPrinter(think, isTTY), [RUN_MODES.AgentChat]: (think, isTTY) => new ChatStreamPrinter(think, isTTY), [RUN_MODES.Completion]: (think, _isTTY) => new CompletionStreamPrinter(think), - [RUN_MODES.Workflow]: (_think, _isTTY) => new WorkflowStreamPrinter(), + [RUN_MODES.Workflow]: (think, _isTTY) => new WorkflowStreamPrinter(think), } export function streamPrinterFor(mode: string, think = false, isTTY = false): StreamPrinter { diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 03963865e56..fdaf8f269ed 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index' import DeleteMember from '@/commands/delete/member/index' import DescribeApp from '@/commands/describe/app/index' import EnvList from '@/commands/env/list/index' -import ExportApp from '@/commands/export/app/index' +import ExportStudioApp from '@/commands/export/studio-app/index' import GetApp from '@/commands/get/app/index' import GetMember from '@/commands/get/member/index' import GetWorkspace from '@/commands/get/workspace/index' -import ImportApp from '@/commands/import/app/index' +import ImportStudioApp from '@/commands/import/studio-app/index' import ResumeApp from '@/commands/resume/app/index' import RunApp from '@/commands/run/app/index' import SetMember from '@/commands/set/member/index' @@ -77,7 +77,7 @@ export const commandTree: CommandTree = { }, export: { subcommands: { - app: { command: ExportApp, subcommands: {} }, + 'studio-app': { command: ExportStudioApp, subcommands: {} }, }, }, get: { @@ -89,7 +89,7 @@ export const commandTree: CommandTree = { }, import: { subcommands: { - app: { command: ImportApp, subcommands: {} }, + 'studio-app': { command: ImportStudioApp, subcommands: {} }, }, }, resume: { diff --git a/cli/src/help/contract.test.ts b/cli/src/help/contract.test.ts new file mode 100644 index 00000000000..272e1ff8d88 --- /dev/null +++ b/cli/src/help/contract.test.ts @@ -0,0 +1,26 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' +import { describe, expect, it } from 'vitest' +import { HttpClientError, newError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' +import { CONTRACT } from './contract' + +describe('errorEnvelope contract', () => { + // Guard against the documented shape drifting from the real envelope: build an + // error with every optional field populated and assert each JSON key it emits + // is named in the contract string. Adding/removing an envelope field without + // updating the doc fails here. + it('documents every key the real JSON envelope can emit', () => { + const server: ErrorBody = { code: 'app_unavailable', message: 'gone', status: 404 } + const err = HttpClientError.from(newError(ErrorCode.Server4xxOther, 'boom')) + .withHint('do x') + .withRequest('GET', 'https://api.dify.ai/v1/me') + .withHttpStatus(404) + .withRawResponse('{"x":1}') + .withServerError(server) + + const env = err.toEnvelope() + + for (const key of Object.keys(env.error)) + expect(CONTRACT.errorEnvelope.shape).toContain(`"${key}"`) + }) +}) diff --git a/cli/src/help/contract.ts b/cli/src/help/contract.ts index 1d68da8b268..d3a7549ecc9 100644 --- a/cli/src/help/contract.ts +++ b/cli/src/help/contract.ts @@ -14,9 +14,9 @@ export type Contract = { } const EXIT_CODE_DESCRIPTIONS: Readonly> = { - [ExitCode.Success]: 'success', + [ExitCode.Success]: 'success (also a workflow paused for human input — check stdout for status "paused")', [ExitCode.Generic]: 'generic error', - [ExitCode.Usage]: 'usage error (bad flag / missing arg), or a workflow paused for human input', + [ExitCode.Usage]: 'usage error (bad flag / missing arg)', [ExitCode.Auth]: 'auth error (not logged in / token expired)', [ExitCode.VersionCompat]: 'version / compatibility error', } @@ -38,11 +38,11 @@ export const CONTRACT: Contract = { description: 'On failure the error goes to stderr. Under -o json/yaml it is a structured envelope; otherwise a human line.', shape: - '{ "error": { "code": string, "message": string, "hint"?: string, "http_status"?: number, "request"?: string } }', + '{ "error": { "code": string, "message": string, "hint"?: string, "http_status"?: number, "method"?: string, "url"?: string, "raw_response"?: string, "server"?: object } }', }, hitl: { description: - 'When a workflow pauses for human input, `run app` exits 2 and writes a JSON object to stdout with status "paused", form_token, workflow_run_id and resolved_default_values.', + 'When a workflow pauses for human input, `run app` exits 0 (success-with-pending) and writes a JSON object to stdout with status "paused", form_token, workflow_run_id and resolved_default_values.', resume: 'difyctl resume app --workflow-run-id [--inputs \'{"key":"value"}\']', }, diff --git a/cli/src/help/skill-template.ts b/cli/src/help/skill-template.ts index 1038a8f99ef..581d6392e13 100644 --- a/cli/src/help/skill-template.ts +++ b/cli/src/help/skill-template.ts @@ -26,7 +26,7 @@ output formats, error envelope, HITL protocol). Treat that JSON as the source of truth; this file only bootstraps you into it. ## The one non-obvious thing: HITL pauses are not failures -A run can pause for human input. It exits with **code 2** and emits a +A run can pause for human input. It exits with **code 0** and emits a \`paused\` JSON payload — this is success-with-pending, NOT a crash. Resume as the payload instructs (see \`difyctl resume app --help\`). diff --git a/cli/src/help/topics.test.ts b/cli/src/help/topics.test.ts index f5b2d1843e9..5a326895035 100644 --- a/cli/src/help/topics.test.ts +++ b/cli/src/help/topics.test.ts @@ -50,10 +50,10 @@ describe('agent topic', () => { }) describe('external topic', () => { - it('mentions external bearer prefix and login flag', () => { + it('mentions external bearer prefix and DIFY_TOKEN onboarding', () => { const out = render('external') expect(out).toContain('dfoe_') - expect(out).toContain('--external') + expect(out).toContain('export DIFY_TOKEN') expect(out).toContain('DIFY_TOKEN') }) diff --git a/cli/src/help/topics.ts b/cli/src/help/topics.ts index 3da5c0f8dfc..de2becdfa36 100644 --- a/cli/src/help/topics.ts +++ b/cli/src/help/topics.ts @@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding difyctl run app "hello" -o json Tips: + * Two app nouns: 'studio-app' is what you build and edit in Studio on the + web console inside a workspace (its source definition — export or move it); + 'app' is a published app you run and inspect. * 'difyctl auth list' shows your authenticated contexts; 'difyctl use host' and 'difyctl use account' switch between them. * Pass --workspace to target a non-default workspace. @@ -37,8 +40,8 @@ const EXTERNAL_HELP_TEXT = `difyctl: external-SSO bearer onboarding smaller dataset: 1. Acquire a token through your SSO provider (out of band). - 2. Hand it to the CLI: - difyctl auth login --external --token "$DIFY_TOKEN" + 2. Hand it to the CLI via the DIFY_TOKEN environment variable: + export DIFY_TOKEN="" 3. List apps your subject is permitted to invoke: difyctl get app @@ -74,6 +77,16 @@ OUTPUT Pass -o json (or -o yaml) on every command — the JSON shape is stable and documented. Without it you get human tables meant for a terminal. +APP vs STUDIO-APP + Two nouns, two faces of the same app: + studio-app what you build and edit in Studio on the web console, + inside a workspace — the app's source definition. + app a published app, live and runnable. + Use 'studio-app' to work with the definition you manage on the website + (export it, move it between workspaces or instances); use 'app' to run + and inspect a published one. The COMMANDS list shows the verbs each + noun supports. + DISCOVERY difyctl help -o json full command tree + this contract, machine-readable difyctl get app -o json list apps (ids + modes) diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts index 0a487723352..3244222da07 100644 --- a/cli/src/http/error-mapper.test.ts +++ b/cli/src/http/error-mapper.test.ts @@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => { }) }) +describe('classifyResponse 403', () => { + it('maps 403 to AccessDenied (exit 4 bucket)', async () => { + const req403 = new Request('https://x/openapi/v1/apps/abc/export') + const res403 = new Response( + JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }), + { status: 403, headers: { 'content-type': 'application/json' } }, + ) + const err = await classifyResponse(req403, res403) + expect(err.code).toBe(ErrorCode.AccessDenied) + expect(err.message).toBe('unsupported_token_type') + }) + + it('403 with no parseable ErrorBody falls back to generic denied message', async () => { + const err = await classified(403, 'not json') + expect(err.code).toBe(ErrorCode.AccessDenied) + expect(err.message).toBe('not permitted') + }) +}) + describe('classifyResponse — non-conforming bodies (no fallback by design)', () => { it('non-JSON body yields no serverError, classification by status', async () => { const err = await classified(502, 'bad gateway') diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index aca1a7e6184..34d7637d4e0 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = { includeRaw: false, } +const ACCESS_DENIED_CLASS: StatusClass = { + code: ErrorCode.AccessDenied, + fallbackMessage: () => 'not permitted', + includeRaw: false, +} + function statusClass(status: number): StatusClass { if (status === 401) return AUTH_EXPIRED_CLASS + if (status === 403) + return ACCESS_DENIED_CLASS if (status === 429) return RATE_LIMITED_CLASS if (status >= 500) diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts index c99232b975f..0d1f2e12d57 100644 --- a/cli/src/http/orpc.test.ts +++ b/cli/src/http/orpc.test.ts @@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => { } it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { - const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 }) + const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 }) expect(caught.code).toBe(ErrorCode.Server4xxOther) - expect(caught.httpStatus).toBe(403) + expect(caught.httpStatus).toBe(422) expect(caught.message).toBe('no access') // Parity with the transport path: the migrated endpoint's error keeps the request // method/url and the raw body, so formatted errors still print the `request:` line diff --git a/cli/src/sys/io/prompt.ts b/cli/src/sys/io/prompt.ts index d5cc2498b96..15b3d95eb5e 100644 --- a/cli/src/sys/io/prompt.ts +++ b/cli/src/sys/io/prompt.ts @@ -33,6 +33,18 @@ function normalize(raw: string, opts: Pick, 'default' return trimmed } +export async function promptConfirm(io: IOStreams, message: string): Promise { + io.err.write(message) + const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false }) + try { + const line = await new Promise(resolve => rl.once('line', resolve)) + return line.trim().toLowerCase() === 'y' + } + finally { + rl.close() + } +} + export async function promptText(opts: PromptTextOptions): Promise { const prompt = buildPromptLine(opts) const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) diff --git a/cli/src/sys/io/think-filter.test.ts b/cli/src/sys/io/think-filter.test.ts index 72ec036abdd..db07d65cd90 100644 --- a/cli/src/sys/io/think-filter.test.ts +++ b/cli/src/sys/io/think-filter.test.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { PassThrough } from 'node:stream' import { describe, expect, it } from 'vitest' -import { extractThinkBlocks, stripThinkBlocks, ThinkChunkFilter } from './think-filter' +import { extractThinkBlocks, filterThinkInOutputs, stripThinkBlocks, ThinkChunkFilter } from './think-filter' function captures() { const out = new PassThrough() @@ -63,6 +63,50 @@ describe('extractThinkBlocks', () => { }) }) +// --- workflow outputs helper --- + +describe('filterThinkInOutputs', () => { + it('no think block — outputs unchanged, thinking empty', () => { + const r = filterThinkInOutputs({ text: 'hello' }, true) + expect(r.outputs).toEqual({ text: 'hello' }) + expect(r.thinking).toBe('') + }) + + it('showThink: false — strips from string field, thinking empty', () => { + const r = filterThinkInOutputs({ text: 'reasoning\nanswer' }, false) + expect(r.outputs).toEqual({ text: 'answer' }) + expect(r.thinking).toBe('') + }) + + it('showThink: true — strips from string field, captures thinking', () => { + const r = filterThinkInOutputs({ text: 'step 1\nfinal' }, true) + expect(r.outputs).toEqual({ text: 'final' }) + expect(r.thinking).toBe('\nstep 1\n') + }) + + it('multiple string fields — thinking joined with separator', () => { + const r = filterThinkInOutputs( + { a: 'x\nfoo', b: 'y\nbar' }, + true, + ) + expect(r.outputs).toEqual({ a: 'foo', b: 'bar' }) + expect(r.thinking).toBe('\nx\n\n---\n\ny\n') + }) + + it('non-string values pass through untouched', () => { + const outputs = { n: 42, flag: true, nested: { k: 'v\nx' }, arr: ['a'], nil: null } + const r = filterThinkInOutputs(outputs, true) + expect(r.outputs).toEqual(outputs) + expect(r.thinking).toBe('') + }) + + it('empty outputs — empty result', () => { + const r = filterThinkInOutputs({}, true) + expect(r.outputs).toEqual({}) + expect(r.thinking).toBe('') + }) +}) + // --- streaming chunk filter --- describe('ThinkChunkFilter — showThink: false (strip)', () => { diff --git a/cli/src/sys/io/think-filter.ts b/cli/src/sys/io/think-filter.ts index 401ea728f24..88c2fb7c717 100644 --- a/cli/src/sys/io/think-filter.ts +++ b/cli/src/sys/io/think-filter.ts @@ -14,6 +14,28 @@ export function extractThinkBlocks(s: string): { clean: string, thinking: string return { clean, thinking: parts.join('\n---\n') } } +// Workflow outputs carry their answer text in top-level string fields rather than +// a single `answer`, so think filtering navigates the outputs object. Nested +// strings (inside arrays/objects) are left untouched. +export function filterThinkInOutputs( + outputs: Record, + showThink: boolean, +): { outputs: Record, thinking: string } { + const thoughts: string[] = [] + const clean: Record = {} + for (const [key, value] of Object.entries(outputs)) { + if (typeof value !== 'string') { + clean[key] = value + continue + } + const extracted = extractThinkBlocks(value) + clean[key] = extracted.clean + if (showThink && extracted.thinking !== '') + thoughts.push(extracted.thinking) + } + return { outputs: clean, thinking: thoughts.join('\n---\n') } +} + function splitAtPotentialTag(s: string, tag: string): [string, string] { const maxHold = tag.length - 1 for (let len = Math.min(maxHold, s.length); len > 0; len--) { diff --git a/cli/src/types/app-meta.test.ts b/cli/src/types/app-meta.test.ts index b62d81579ea..c1ac765190d 100644 --- a/cli/src/types/app-meta.test.ts +++ b/cli/src/types/app-meta.test.ts @@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse { name: 'Greeter', description: '', mode: 'chat', - author: 'tester', - tags: [], updated_at: undefined, service_api_enabled: false, is_agent: false, diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 35e171ecafb..20b23295acb 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -519,14 +519,14 @@ async function provisionApps( async function importAppCli(filePath: string, wsId: string): Promise { const result = await run( - ['import', 'app', '--from-file', filePath, '--workspace', wsId], + ['import', 'studio-app', '--from-file', filePath, '--workspace', wsId], { configDir, timeout: 60_000 }, ) if (result.exitCode !== 0) - throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`) + throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`) const match = result.stderr.match(/app ([0-9a-f-]{36})/) if (!match?.[1]) - throw new Error(`import app: could not parse app_id: ${result.stderr}`) + throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`) return match[1] } diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts index a1dea13e66a..dd83592be36 100644 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts @@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { expect(line.trim()).not.toMatch(/\s/) }) - itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => { + itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => { const tc = await withTempConfig() try { const { mkdir, writeFile } = await import('node:fs/promises') @@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { await mkdir(tc.configDir, { recursive: true }) await writeFile( join(tc.configDir, 'hosts.yml'), - `${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`, + `${[ + `current_host: ${E.host}`, + 'token_storage: file', + 'tokens:', + ` bearer: ${E.ssoToken}`, + 'external_subject:', + ' email: sso@example.com', + ' issuer: https://issuer.example.com', + ].join('\n')}\n`, { mode: 0o600 }, ) const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - expect(r.exitCode).not.toBe(0) - assertErrorEnvelope(r) + assertExitCode(r, 0) + const parsed = assertJson<{ data: unknown[] }>(r) + expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true) } finally { await tc.cleanup() } }) diff --git a/cli/test/e2e/suites/auth/whoami.e2e.ts b/cli/test/e2e/suites/auth/whoami.e2e.ts index 2caec57dd7e..69404bb9550 100644 --- a/cli/test/e2e/suites/auth/whoami.e2e.ts +++ b/cli/test/e2e/suites/auth/whoami.e2e.ts @@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => { }) } + const itWithSso = optionalIt(Boolean(E.ssoToken)) + // ── auth whoami — internal user ────────────────────────────────────────────── it('[P0] internal user auth whoami outputs email', async () => { @@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => { expect(result.exitCode).not.toBe(0) }) - it('[P0] external user get app returns insufficient_scope error', async () => { - // Spec: external user get app returns insufficient_scope + itWithSso('[P0] external user can list permitted apps via SSO token', async () => { + // External users read apps via the permitted-external surface (no workspace scope). await withSSOAuth() const result = await r(['get', 'app']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) }) it('[P0] external user whoami outputs SSO email', async () => { @@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => { expect(result.stdout).toContain('sso-user@example.com') }) - const itWithSso = optionalIt(Boolean(E.ssoToken)) - itWithSso('[P0] external user can execute run app using SSO token', async () => { await injectSsoAuth(configDir, { host: E.host, diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts index 75cfe226370..68902a32da6 100644 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ b/cli/test/e2e/suites/discovery/describe-app.e2e.ts @@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => { expect(result.stdout).toMatch(/Name:/i) }) - it('[P1] describe output contains Tags field', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Tags:/i) - }) - // ── Input schema ────────────────────────────────────────────────────────── it('[P0] describe output contains Parameters section', async () => { @@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => { - // Spec 3.86: dfoe_ token → insufficient_scope, exit non-0. + itWithSso('[P0] external SSO user can describe a permitted app', async () => { + // A dfoe_ token resolves `describe app` via the permitted-external surface + // (not the account /apps surface), so a permitted app describes successfully. // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') @@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/ID:/i) + expect(result.stdout).toContain(E.chatAppId) + expect(result.stdout).toMatch(/Mode:/i) } finally { await ssoTmp.cleanup() @@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => { expect(result.stdout).toContain('e2e-test') }) - it('[P1] describe output contains Author field (3.67)', async () => { - // Spec 3.67: output includes Author field when app has an author. - const result = await withRetry( - () => fx.r(['describe', 'app', E.chatAppId]), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Author:/i) - }) - it('[P0] Inputs section shows parameter names (3.70)', async () => { // Spec 3.70: Parameters/Inputs section displays variable names. // workflow app has x, num, enum_var, paragraph. diff --git a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts index 38d9e0a427f..54feaf9a34c 100644 --- a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts @@ -61,7 +61,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => { // Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode. - // Default table shows priority:0 columns only (NAME/ID/MODE/TAGS/UPDATED). + // Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED). const wideResult = await withRetry( () => fx.r(['get', 'app', '-A', '-o', 'wide']), { attempts: 3, delayMs: 2000 }, @@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => { - // Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0. - // Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN. + itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => { + // --all-workspaces is meaningless for external SSO users (no workspace + // scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2). + // Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') const ssoTmp = await withTempConfig() try { await mkdir(ssoTmp.configDir, { recursive: true }) - // Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path. const hostsYml = `${[ `current_host: ${E.host}`, `token_storage: file`, @@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i) + assertExitCode(result, 2) + expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/) } finally { await ssoTmp.cleanup() diff --git a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts index 781d351826b..2e7623933b0 100644 --- a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts @@ -8,7 +8,6 @@ * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app */ -import { Buffer } from 'node:buffer' import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest' import { assertErrorEnvelope, @@ -99,8 +98,8 @@ describe('E2E / difyctl get app (list)', () => { it('[P1] -o wide outputs extended fields', async () => { const result = await fx.r(['get', 'app', '-o', 'wide']) assertExitCode(result, 0) - // wide adds AUTHOR and WORKSPACE columns - expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i) + // wide adds the WORKSPACE column + expect(result.stdout).toMatch(/WORKSPACE/i) }) it('[P1] output is pipe-friendly in JSON mode', async () => { @@ -206,17 +205,15 @@ describe('E2E / difyctl get app (list)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => { - // Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1. + itWithSso('[P0] external SSO user can list permitted apps', async () => { + // A dfoe_ token lists apps via the permitted-external surface + // (apps:read:permitted-external scope), with no workspace scoping. // Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured). const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') const ssoTmp = await withTempConfig() try { await mkdir(ssoTmp.configDir, { recursive: true }) - // SSO (dfoe_) users have apps:run scope only, not apps:list. - // Inject a minimal hosts.yml without workspace so the CLI reaches the - // scope-check path rather than resolving the workspace successfully. const hostsYml = `${[ `current_host: ${E.host}`, `token_storage: file`, @@ -228,8 +225,8 @@ describe('E2E / difyctl get app (list)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) } finally { await ssoTmp.cleanup() @@ -348,114 +345,4 @@ describe('E2E / difyctl get app (list)', () => { await networkTmp.cleanup() } }) - - it('[P1] --tag filter returns only apps that carry the specified tag (3.20)', async () => { - // Spec 3.20: --tag performs exact tag-name match. - // - // Before asserting: ensure echo-chat app has the 'e2e-test' tag. - // 1. GET /console/api/tags?type=app&keyword=e2e-test → find or confirm tag exists - // 2. POST /console/api/tags → create tag when absent - // 3. GET /console/api/apps/ → check existing bindings - // 4. POST /console/api/tag-bindings → bind when not yet bound - - const base = E.host.replace(/\/$/, '') - - // ── Console login: obtain cookie + CSRF (console API rejects dfoa_ Bearer) ── - const passwordB64 = Buffer.from(E.password, 'utf8').toString('base64') - const loginRes = await fetch(`${base}/console/api/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: E.email, password: passwordB64, remember_me: false }), - }) - expect(loginRes.ok, `console login failed: ${loginRes.status}`).toBe(true) - - // Helper: extract cookie string + csrf from Set-Cookie array - function parseCookies(res: Response): { cookieString: string, csrfToken: string } { - const setCookies = res.headers.getSetCookie?.() ?? [] - const cookieString = setCookies.map(kv => kv.split(';')[0]).join('; ') - const csrfPair = setCookies.map(kv => kv.split(';')[0]).filter((p): p is string => typeof p === 'string' && p.includes('csrf_token='))[0] - const csrfToken = csrfPair !== undefined - ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) - : '' - return { cookieString, csrfToken } - } - - let { cookieString, csrfToken } = parseCookies(loginRes) - - // ── Switch to the workspace that contains the test fixtures ────────────── - // E.workspaceId is resolved by global-setup; tag-bindings scope to the active workspace. - const switchRes = await fetch(`${base}/console/api/workspaces/switch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ tenant_id: E.workspaceId }), - }) - // After workspace switch the server issues fresh cookies; use them for all subsequent calls. - if (switchRes.ok && switchRes.headers.getSetCookie?.().length) { - const switched = parseCookies(switchRes) - cookieString = switched.cookieString - csrfToken = switched.csrfToken - } - - const headers: Record = { - 'Content-Type': 'application/json', - 'Cookie': cookieString, - 'X-CSRF-Token': csrfToken, - } - - // ── Step 1: find the 'e2e-test' app tag ────────────────────────────────── - const tagsRes = await fetch(`${base}/console/api/tags?type=app&keyword=e2e-test`, { headers }) - expect(tagsRes.ok, `GET /tags failed: ${tagsRes.status}`).toBe(true) - const tagsList = await tagsRes.json() as Array<{ id: string, name: string }> - let tagId = tagsList.find(t => t.name === 'e2e-test')?.id - - // ── Step 2: create the tag if it doesn't exist yet ─────────────────────── - if (!tagId) { - const createRes = await fetch(`${base}/console/api/tags`, { - method: 'POST', - headers, - body: JSON.stringify({ name: 'e2e-test', type: 'app' }), - }) - expect(createRes.ok, `POST /tags failed: ${createRes.status}`).toBe(true) - const created = await createRes.json() as { id: string, name: string } - tagId = created.id - } - - expect(tagId, 'tag id must be resolved').toBeTruthy() - - // ── Step 3 & 4: bind tag idempotently (tag-bindings is idempotent on duplicates) ── - const bindRes = await fetch(`${base}/console/api/tag-bindings`, { - method: 'POST', - headers, - body: JSON.stringify({ - tag_ids: [tagId], - target_id: E.chatAppId, - type: 'app', - }), - }) - // Accept 200 (bound) or 409/4xx if already bound — binding is idempotent - expect( - bindRes.ok || bindRes.status === 409, - `POST /tag-bindings failed unexpectedly: ${bindRes.status}`, - ).toBe(true) - - // ── Assertion: difyctl --tag e2e-test returns echo-chat ────────────────── - const result = await fx.r(['get', 'app', '--tag', 'e2e-test', '-o', 'json']) - assertExitCode(result, 0) - const parsed = assertJson<{ data: Array<{ id: string, name: string, tags: Array<{ name: string }> }> }>(result) - - // echo-chat must appear in the filtered list - const echoChatInResult = parsed.data.find(app => app.id === E.chatAppId) - expect( - echoChatInResult, - `echo-chat (id=${E.chatAppId}) should appear in --tag e2e-test results`, - ).toBeDefined() - - // Every returned app must carry the e2e-test tag - parsed.data.forEach(app => - expect( - app.tags.some(t => t.name === 'e2e-test'), - `app "${app.name}" should carry the e2e-test tag`, - ).toBe(true), - ) - }) }) diff --git a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts index b09ce25d679..b620eb383ef 100644 --- a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts @@ -68,8 +68,9 @@ describe('E2E / difyctl get app (single)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.55)', async () => { - // Spec 3.55: dfoe_ token on get app → insufficient_scope, exit 1. + itWithSso('[P0] external SSO user can get a permitted app by id', async () => { + // A dfoe_ token resolves get app via the permitted-external describe + // surface (apps:read:permitted-external scope), so a permitted app is returned. // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') @@ -87,8 +88,8 @@ describe('E2E / difyctl get app (single)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toContain(E.chatAppId) } finally { await ssoTmp.cleanup() @@ -153,13 +154,13 @@ describe('E2E / difyctl get app (single)', () => { }) it('[P1] get app -o wide outputs extended columns (3.48)', async () => { - // Spec 3.48: -o wide → TAGS/UPDATED/AUTHOR columns, exit 0. + // Spec 3.48: -o wide → UPDATED/WORKSPACE columns, exit 0. const result = await withRetry( () => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']), { attempts: 3, delayMs: 2000 }, ) assertExitCode(result, 0) - expect(result.stdout).toMatch(/AUTHOR|UPDATED|TAGS/i) + expect(result.stdout).toMatch(/UPDATED|WORKSPACE/i) }) it('[P1] get app -o json is pipe-friendly with no ANSI (3.49)', async () => { diff --git a/cli/test/e2e/suites/dsl/export-app.e2e.ts b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts similarity index 82% rename from cli/test/e2e/suites/dsl/export-app.e2e.ts rename to cli/test/e2e/suites/dsl/export-studio-app.e2e.ts index f96fbd216a4..e158ceaccec 100644 --- a/cli/test/e2e/suites/dsl/export-app.e2e.ts +++ b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts @@ -1,5 +1,5 @@ /** - * E2E: difyctl export app — DSL export + * E2E: difyctl export studio-app — DSL export * * Prerequisites (DIFY_E2E_* env vars): * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency) @@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js' const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities const E = resolveEnv(caps) -describe('E2E / difyctl export app', () => { +describe('E2E / difyctl export studio-app', () => { let fx: AuthFixture beforeEach(async () => { @@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => { // ── Basic export ────────────────────────────────────────────────────────── it('[P0] exported DSL is non-empty YAML printed to stdout', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout.trim().length).toBeGreaterThan(0) }) it('[P0] exported YAML contains kind: app', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^kind:\s*app/m) }) it('[P0] exported YAML contains version field', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^version:/m) }) it('[P0] exported YAML contains app section with mode', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^\s+mode:/m) }) it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout.endsWith('\n')).toBe(true) }) it('[P1] chat app export also succeeds and includes mode', async () => { - const result = await fx.r(['export', 'app', E.chatAppId]) + const result = await fx.r(['export', 'studio-app', E.chatAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^kind:\s*app/m) expect(result.stdout).toMatch(/^\s+mode:/m) @@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-')) const outPath = join(dir, 'exported.yaml') try { - const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]) assertExitCode(result, 0) const content = await readFile(outPath, 'utf8') expect(content).toMatch(/^kind:\s*app/m) @@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => { const outPath = join(dir, 'exported.yaml') try { const [stdoutResult, fileResult] = await Promise.all([ - fx.r(['export', 'app', E.workflowAppId]), - fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => { + fx.r(['export', 'studio-app', E.workflowAppId]), + fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => { const content = await readFile(outPath, 'utf8') return { exitCode: r.exitCode, content } }), @@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-')) const dslPath = join(dir, 'roundtrip.yaml') try { - const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath]) + const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath]) assertExitCode(exportResult, 0) const importResult = await fx.r([ 'import', - 'app', + 'studio-app', '--from-file', dslPath, '--name', @@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => { // ── Error scenarios ─────────────────────────────────────────────────────── it('[P0] non-existent app returns exit code 1 with error in stderr', async () => { - const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e']) + const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e']) expect(result.exitCode).toBe(1) expect(result.stderr.length).toBeGreaterThan(0) }) @@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => { it('[P0] unauthenticated export returns auth error (exit code 4)', async () => { const unauthTmp = await withTempConfig() try { - const result = await run(['export', 'app', E.workflowAppId], { + const result = await run(['export', 'studio-app', E.workflowAppId], { configDir: unauthTmp.configDir, }) assertExitCode(result, 4) @@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => { }) it('[P1] export with missing app id argument exits non-zero', async () => { - const result = await fx.r(['export', 'app']) + const result = await fx.r(['export', 'studio-app']) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/missing required argument|required|app id/i) }) it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid']) + const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid']) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/http_status:\s*4\d\d/) expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/) @@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => { it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => { const result = await fx.r([ 'export', - 'app', + 'studio-app', E.workflowAppId, '--workflow-id', '00000000-0000-0000-0000-000000000000', @@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-')) const outPath = join(dir, 'should-not-exist.yaml') try { - const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath]) + const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath]) expect(result.exitCode).not.toBe(0) const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false) expect(exists, 'output file must not be created on export failure').toBe(false) diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index 932998d9afe..5c4a9f79fa0 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -82,10 +82,9 @@ describe('E2E / error message standards (spec 5.3)', () => { // ── 5.63 dfoe_ token insufficient_scope ────────────────────────────────── - itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => { - // Spec 5.63: an external SSO token (dfoe_) must not be able to access - // internal management APIs; the CLI must return an insufficient_scope - // error with exit 1. + itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => { + // A dfoe_ SSO token is rejected with a non-zero exit when it targets an + // account-only management command (`export studio-app`). const { mkdir } = await import('node:fs/promises') const ssoTmp = await withTempConfig() try { @@ -95,16 +94,13 @@ describe('E2E / error message standards (spec 5.3)', () => { `token_storage: file`, `tokens:`, ` bearer: ${E.ssoToken}`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "${E.workspaceName}"`, - ` role: member`, + `external_subject:`, + ` email: sso@example.com`, + ` issuer: https://issuer.example.com`, ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) + const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir }) assertNonZeroExit(result) - // In this environment ssoToken may be a dfoa_ token; the server returns - // either insufficient_scope or server_5xx — both are non-zero exits. expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) } finally { diff --git a/cli/test/e2e/suites/output/table-output.e2e.ts b/cli/test/e2e/suites/output/table-output.e2e.ts index c3300f8bec0..ecfb0577d96 100644 --- a/cli/test/e2e/suites/output/table-output.e2e.ts +++ b/cli/test/e2e/suites/output/table-output.e2e.ts @@ -41,7 +41,7 @@ import type { AuthFixture } from '../../helpers/cli.js' import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest' import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js' import { withAuthFixture } from '../../helpers/cli.js' -import { loadE2EEnv, resolveEnv } from '../../setup/env.js' +import { resolveEnv } from '../../setup/env.js' // @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities @@ -65,19 +65,18 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () expect(result.stdout.trim().length).toBeGreaterThan(0) }) - it('[P0] 5.2 header row contains all five expected column names', async () => { - // Spec 5.2: header columns are NAME / ID / MODE / TAGS / UPDATED. + it('[P0] 5.2 header row contains all four expected column names', async () => { + // Spec 5.2: header columns are NAME / ID / MODE / UPDATED. const result = await fx.r(['get', 'app']) assertExitCode(result, 0) const header = result.stdout.split('\n')[0] ?? '' expect(header).toMatch(/NAME/i) expect(header).toMatch(/ID/i) expect(header).toMatch(/MODE/i) - expect(header).toMatch(/TAGS/i) expect(header).toMatch(/UPDATED/i) }) - it('[P0] 5.3 column order is NAME → ID → MODE → TAGS → UPDATED', async () => { + it('[P0] 5.3 column order is NAME → ID → MODE → UPDATED', async () => { // Spec 5.3: columns appear in the defined order (as verified from actual CLI output). const result = await fx.r(['get', 'app']) assertExitCode(result, 0) @@ -85,19 +84,16 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () const nameIdx = header.indexOf('NAME') const idIdx = header.indexOf('ID') const modeIdx = header.indexOf('MODE') - const tagsIdx = header.indexOf('TAGS') const updatedIdx = header.indexOf('UPDATED') // All columns must be present expect(nameIdx).toBeGreaterThanOrEqual(0) expect(idIdx).toBeGreaterThanOrEqual(0) expect(modeIdx).toBeGreaterThanOrEqual(0) - expect(tagsIdx).toBeGreaterThanOrEqual(0) expect(updatedIdx).toBeGreaterThanOrEqual(0) // Verify left-to-right order expect(nameIdx).toBeLessThan(idIdx) expect(idIdx).toBeLessThan(modeIdx) - expect(modeIdx).toBeLessThan(tagsIdx) - expect(tagsIdx).toBeLessThan(updatedIdx) + expect(modeIdx).toBeLessThan(updatedIdx) }) it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => { @@ -153,32 +149,6 @@ describe('E2E / table output — header and column format (spec 5.1–5.19)', () expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/) }) - // ── 5.17 — Empty-field rendering ───────────────────────────────────────── - - it('[P1] 5.17 empty TAGS field is rendered as blank — not as a dash (-)', async () => { - // Spec 5.17: empty fields show blank, not the `-` placeholder. - // Most apps in the fixture workspace have no tags. - const result = await fx.r(['get', 'app']) - assertExitCode(result, 0) - const lines = result.stdout.trim().split('\n') - const header = lines[0] ?? '' - const tagsStart = header.indexOf('TAGS') - const updatedStart = header.indexOf('UPDATED') - // Check at least one data row: the TAGS slice should be blank, not '-' - const dataLines = lines.slice(1).filter(l => l.trim()) - if (dataLines.length > 0 && tagsStart >= 0 && updatedStart > tagsStart) { - const tagsSlice = (dataLines[0] ?? '').substring(tagsStart, updatedStart).trim() - // If there are no tags, the slice should be empty (not contain a lone '-') - if (tagsSlice === '') { - expect(tagsSlice).toBe('') - } - else { - // Tags are present — just verify it's not the placeholder dash - expect(tagsSlice).not.toBe('-') - } - } - }) - // ── 5.25 — Performance ──────────────────────────────────────────────────── it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => { diff --git a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts b/cli/test/e2e/suites/run/run-app-hitl.e2e.ts index a582bc5282c..58290331c90 100644 --- a/cli/test/e2e/suites/run/run-app-hitl.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-hitl.e2e.ts @@ -386,15 +386,7 @@ describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', ( await fx.cleanup() }) - it('[P1] 4.5.8 HITL pause with display_in_ui=false: JSON contains display_in_ui=false and exit is 0', async () => { - // Spec 4.5.8: when the Human Input node has display_in_ui=false the CLI - // should indicate the form is delivered via an external channel. - // - // Current CLI behaviour (v1.0): the JSON field display_in_ui is correctly - // set to false. The stderr hint still includes the resume command (the - // "form delivered via external channel" hint is not yet implemented in CLI). - // This test verifies the current actual behaviour and will need updating - // once the CLI implements the display_in_ui=false hint distinction. + it('[P1] 4.5.8 HITL pause with display_in_ui=false: external-channel form is not CLI-resumable', async () => { const result = await fx.r([ 'run', 'app', @@ -407,7 +399,8 @@ describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', ( const parsed = assertJson<{ status: string display_in_ui: boolean - form_token: string + form_token: string | null + approval_channels: string[] workflow_run_id: string }>(result) @@ -417,12 +410,13 @@ describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', ( // status must be paused expect(parsed.status).toBe('paused') - // form_token must be present (resume is still possible even for external delivery) - expect(parsed.form_token, 'form_token must be non-empty').toBeTruthy() + // external delivery is not CLI-resumable: no token, channels name the real route + expect(parsed.form_token, 'form_token must be null for external delivery').toBeNull() + expect(parsed.approval_channels, 'approval_channels must name the delivery channel').toContain('email') - // stderr must contain a hint (current behaviour: hint includes resume command) - expect(result.stderr.trim().length, 'stderr must contain a hint').toBeGreaterThan(0) - expect(result.stderr).toMatch(/hint|resume|paused/i) + // stderr hint must describe the channel, not offer a resume command + expect(result.stderr).toMatch(/delivered via|resume only from/i) + expect(result.stderr).not.toMatch(/difyctl resume/i) }) }) diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 8ee839381c5..221ccbb6b81 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -14,6 +14,7 @@ export type Scenario | 'server-version-empty' | 'server-version-unsupported' | 'run-422-stale' + | 'workflow-think' | 'import-pending' | 'import-failed' diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 96edc96f9ba..4b119286dbc 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -269,8 +269,34 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { name: app.name, description: app.description, mode: app.mode, - author: app.author ?? '', - tags: app.tags, + updated_at: app.updated_at, + service_api_enabled: app.service_api_enabled ?? false, + is_agent: app.is_agent ?? false, + } + : null, + parameters: wantParams ? (app.parameters ?? null) : null, + input_schema: wantInputSchema ? (app.input_schema ?? null) : null, + }) + }) + + app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => { + const id = c.req.param('id') + const fieldsRaw = c.req.query('fields') ?? '' + const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '') + // External subjects have no workspace scope; the app is reachable across workspaces. + const app = APPS.find(a => a.id === id) + if (app === undefined) + return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + const wantInfo = fields.length === 0 || fields.includes('info') + const wantParams = fields.length === 0 || fields.includes('parameters') + const wantInputSchema = fields.length === 0 || fields.includes('input_schema') + return c.json({ + info: wantInfo + ? { + id: app.id, + name: app.name, + description: app.description, + mode: app.mode, updated_at: app.updated_at, service_api_enabled: app.service_api_enabled ?? false, is_agent: app.is_agent ?? false, @@ -337,6 +363,13 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { if (scenario === 'hitl-pause') { return new Response(hitlPauseResponse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }) } + if (scenario === 'workflow-think') { + const thinkSse = sseChunks([ + { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, + { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: 'secret reasoning\nfinal answer' } } } }, + ]) + return new Response(thinkSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + } const sse = streamingRunResponse(app.mode, query, isAgent) return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) }) diff --git a/docker/.env.example b/docker/.env.example index 5e13db9cbc4..78ebc3e4df1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -120,7 +120,6 @@ CELERY_TASK_ANNOTATIONS=null EVENT_BUS_REDIS_URL= EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub EVENT_BUS_REDIS_USE_CLUSTERS=false -EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 # Web and app limits WEB_API_CORS_ALLOW_ORIGINS=* diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7e4521f51cb..a9975b4476e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -986,6 +986,9 @@ "jsx-a11y/click-events-have-key-events": { "count": 1 }, + "jsx-a11y/no-noninteractive-element-to-interactive-role": { + "count": 1 + }, "jsx-a11y/no-static-element-interactions": { "count": 1 } @@ -1003,6 +1006,11 @@ "count": 3 } }, + "web/app/components/apps/starred-app-card.tsx": { + "jsx-a11y/no-noninteractive-element-to-interactive-role": { + "count": 1 + } + }, "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2602,11 +2610,6 @@ "count": 1 } }, - "web/app/components/base/zendesk/utils.ts": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/billing/header-billing-btn/index.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3364,14 +3367,6 @@ "count": 1 } }, - "web/app/components/datasets/list/dataset-card/index.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/list/dataset-card/operation-item.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 diff --git a/package.json b/package.json index b9cb1274a2b..1b9c8c67954 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "dify", "type": "module", "private": true, - "packageManager": "pnpm@11.6.0", + "packageManager": "pnpm@11.8.0", "devEngines": { "runtime": { "name": "node", diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index fbfca3be118..99f44301703 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -4,6 +4,8 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zDeleteAgentByAgentIdApiKeysByApiKeyIdPath, + zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse, zDeleteAgentByAgentIdFilesPath, zDeleteAgentByAgentIdFilesQuery, zDeleteAgentByAgentIdFilesResponse, @@ -11,6 +13,10 @@ import { zDeleteAgentByAgentIdResponse, zDeleteAgentByAgentIdSkillsBySlugPath, zDeleteAgentByAgentIdSkillsBySlugResponse, + zGetAgentByAgentIdApiAccessPath, + zGetAgentByAgentIdApiAccessResponse, + zGetAgentByAgentIdApiKeysPath, + zGetAgentByAgentIdApiKeysResponse, zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath, zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse, zGetAgentByAgentIdChatMessagesPath, @@ -29,6 +35,10 @@ import { zGetAgentByAgentIdDriveFilesPreviewResponse, zGetAgentByAgentIdDriveFilesQuery, zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath, + zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse, + zGetAgentByAgentIdDriveSkillsPath, + zGetAgentByAgentIdDriveSkillsResponse, zGetAgentByAgentIdLogsByConversationIdMessagesPath, zGetAgentByAgentIdLogsByConversationIdMessagesQuery, zGetAgentByAgentIdLogsByConversationIdMessagesResponse, @@ -61,6 +71,11 @@ import { zGetAgentQuery, zGetAgentResponse, zPostAgentBody, + zPostAgentByAgentIdApiEnableBody, + zPostAgentByAgentIdApiEnablePath, + zPostAgentByAgentIdApiEnableResponse, + zPostAgentByAgentIdApiKeysPath, + zPostAgentByAgentIdApiKeysResponse, zPostAgentByAgentIdChatMessagesByTaskIdStopPath, zPostAgentByAgentIdChatMessagesByTaskIdStopResponse, zPostAgentByAgentIdComposerValidateBody, @@ -86,6 +101,8 @@ import { zPostAgentByAgentIdSkillsUploadBody, zPostAgentByAgentIdSkillsUploadPath, zPostAgentByAgentIdSkillsUploadResponse, + zPostAgentByAgentIdVersionsByVersionIdRestorePath, + zPostAgentByAgentIdVersionsByVersionIdRestoreResponse, zPostAgentResponse, zPutAgentByAgentIdBody, zPutAgentByAgentIdComposerBody, @@ -110,10 +127,87 @@ export const inviteOptions = { get, } +export const get2 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdApiAccess', + path: '/agent/{agent_id}/api-access', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdApiAccessPath })) + .output(zGetAgentByAgentIdApiAccessResponse) + +export const apiAccess = { + get: get2, +} + +export const post = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdApiEnable', + path: '/agent/{agent_id}/api-enable', + tags: ['console'], + }) + .input( + z.object({ body: zPostAgentByAgentIdApiEnableBody, params: zPostAgentByAgentIdApiEnablePath }), + ) + .output(zPostAgentByAgentIdApiEnableResponse) + +export const apiEnable = { + post, +} + +export const delete_ = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdApiKeysByApiKeyId', + path: '/agent/{agent_id}/api-keys/{api_key_id}', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdApiKeysByApiKeyIdPath })) + .output(zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse) + +export const byApiKeyId = { + delete: delete_, +} + +export const get3 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdApiKeys', + path: '/agent/{agent_id}/api-keys', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdApiKeysPath })) + .output(zGetAgentByAgentIdApiKeysResponse) + +export const post2 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdApiKeys', + path: '/agent/{agent_id}/api-keys', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdApiKeysPath })) + .output(zPostAgentByAgentIdApiKeysResponse) + +export const apiKeys = { + get: get3, + post: post2, + byApiKeyId, +} + /** * Get suggested questions for an Agent App message */ -export const get2 = oc +export const get4 = oc .route({ description: 'Get suggested questions for an Agent App message', inputStructure: 'detailed', @@ -126,7 +220,7 @@ export const get2 = oc .output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get2, + get: get4, } export const byMessageId = { @@ -136,7 +230,7 @@ export const byMessageId = { /** * Stop a running Agent App chat message generation */ -export const post = oc +export const post3 = oc .route({ description: 'Stop a running Agent App chat message generation', inputStructure: 'detailed', @@ -149,7 +243,7 @@ export const post = oc .output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse) export const stop = { - post, + post: post3, } export const byTaskId = { @@ -159,7 +253,7 @@ export const byTaskId = { /** * Get Agent App chat messages for a conversation with pagination */ -export const get3 = oc +export const get5 = oc .route({ description: 'Get Agent App chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -177,12 +271,12 @@ export const get3 = oc .output(zGetAgentByAgentIdChatMessagesResponse) export const chatMessages = { - get: get3, + get: get5, byMessageId, byTaskId, } -export const get4 = oc +export const get6 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -194,10 +288,10 @@ export const get4 = oc .output(zGetAgentByAgentIdComposerCandidatesResponse) export const candidates = { - get: get4, + get: get6, } -export const post2 = oc +export const post4 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -214,10 +308,10 @@ export const post2 = oc .output(zPostAgentByAgentIdComposerValidateResponse) export const validate = { - post: post2, + post: post4, } -export const get5 = oc +export const get7 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -240,13 +334,13 @@ export const put = oc .output(zPutAgentByAgentIdComposerResponse) export const composer = { - get: get5, + get: get7, put, candidates, validate, } -export const post3 = oc +export const post5 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -259,13 +353,13 @@ export const post3 = oc .output(zPostAgentByAgentIdCopyResponse) export const copy = { - post: post3, + post: post5, } /** * Time-limited external signed URL for one Agent App drive value */ -export const get6 = oc +export const get8 = oc .route({ description: 'Time-limited external signed URL for one Agent App drive value', inputStructure: 'detailed', @@ -283,13 +377,13 @@ export const get6 = oc .output(zGetAgentByAgentIdDriveFilesDownloadResponse) export const download = { - get: get6, + get: get8, } /** * Truncated text preview of one Agent App drive value */ -export const get7 = oc +export const get9 = oc .route({ description: 'Truncated text preview of one Agent App drive value', inputStructure: 'detailed', @@ -307,13 +401,13 @@ export const get7 = oc .output(zGetAgentByAgentIdDriveFilesPreviewResponse) export const preview = { - get: get7, + get: get9, } /** * List agent drive entries for an Agent App */ -export const get8 = oc +export const get10 = oc .route({ description: 'List agent drive entries for an Agent App', inputStructure: 'detailed', @@ -331,19 +425,63 @@ export const get8 = oc .output(zGetAgentByAgentIdDriveFilesResponse) export const files = { - get: get8, + get: get10, download, preview, } +/** + * Inspect one drive-backed skill for slash-menu hover/detail UI + */ +export const get11 = oc + .route({ + description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveSkillsBySkillPathInspect', + path: '/agent/{agent_id}/drive/skills/{skill_path}/inspect', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath })) + .output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse) + +export const inspect = { + get: get11, +} + +export const bySkillPath = { + inspect, +} + +/** + * List drive-backed skills for an Agent App + */ +export const get12 = oc + .route({ + description: 'List drive-backed skills for an Agent App', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveSkills', + path: '/agent/{agent_id}/drive/skills', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdDriveSkillsPath })) + .output(zGetAgentByAgentIdDriveSkillsResponse) + +export const skills = { + get: get12, + bySkillPath, +} + export const drive = { files, + skills, } /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ -export const post4 = oc +export const post6 = oc .route({ description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', inputStructure: 'detailed', @@ -358,13 +496,13 @@ export const post4 = oc .output(zPostAgentByAgentIdFeaturesResponse) export const features = { - post: post4, + post: post6, } /** * Create or update Agent App message feedback */ -export const post5 = oc +export const post7 = oc .route({ description: 'Create or update Agent App message feedback', inputStructure: 'detailed', @@ -379,13 +517,13 @@ export const post5 = oc .output(zPostAgentByAgentIdFeedbacksResponse) export const feedbacks = { - post: post5, + post: post7, } /** * Delete one Agent App drive file by key */ -export const delete_ = oc +export const delete2 = oc .route({ description: 'Delete one Agent App drive file by key', inputStructure: 'detailed', @@ -402,7 +540,7 @@ export const delete_ = oc /** * Commit an uploaded file into the Agent App drive under files/ */ -export const post6 = oc +export const post8 = oc .route({ description: 'Commit an uploaded file into the Agent App drive under files/', inputStructure: 'detailed', @@ -416,11 +554,11 @@ export const post6 = oc .output(zPostAgentByAgentIdFilesResponse) export const files2 = { - delete: delete_, - post: post6, + delete: delete2, + post: post8, } -export const get9 = oc +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -432,10 +570,10 @@ export const get9 = oc .output(zGetAgentByAgentIdLogSourcesResponse) export const logSources = { - get: get9, + get: get13, } -export const get10 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -452,14 +590,14 @@ export const get10 = oc .output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse) export const messages = { - get: get10, + get: get14, } export const byConversationId = { messages, } -export const get11 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -473,14 +611,14 @@ export const get11 = oc .output(zGetAgentByAgentIdLogsResponse) export const logs = { - get: get11, + get: get15, byConversationId, } /** * Get Agent App message details by ID */ -export const get12 = oc +export const get16 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -493,7 +631,7 @@ export const get12 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get12, + get: get16, } export const messages2 = { @@ -503,7 +641,7 @@ export const messages2 = { /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get13 = oc +export const get17 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -516,13 +654,13 @@ export const get13 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get13, + get: get17, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get14 = oc +export const get18 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -540,13 +678,13 @@ export const get14 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get14, + get: get18, } /** * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const post7 = oc +export const post9 = oc .route({ description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -564,13 +702,13 @@ export const post7 = oc .output(zPostAgentByAgentIdSandboxFilesUploadResponse) export const upload = { - post: post7, + post: post9, } /** * List a directory in an Agent App conversation sandbox */ -export const get15 = oc +export const get19 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -588,7 +726,7 @@ export const get15 = oc .output(zGetAgentByAgentIdSandboxFilesResponse) export const files3 = { - get: get15, + get: get19, read, upload, } @@ -600,7 +738,7 @@ export const sandbox = { /** * Upload + standardize a Skill into an Agent App drive */ -export const post8 = oc +export const post10 = oc .route({ description: 'Upload + standardize a Skill into an Agent App drive', inputStructure: 'detailed', @@ -619,13 +757,13 @@ export const post8 = oc .output(zPostAgentByAgentIdSkillsUploadResponse) export const upload2 = { - post: post8, + post: post10, } /** * Infer CLI tool + ENV suggestions from a standardized Agent App skill */ -export const post9 = oc +export const post11 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', inputStructure: 'detailed', @@ -638,13 +776,13 @@ export const post9 = oc .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) export const inferTools = { - post: post9, + post: post11, } /** * Delete a standardized skill from an Agent App drive */ -export const delete2 = oc +export const delete3 = oc .route({ description: 'Delete a standardized skill from an Agent App drive', inputStructure: 'detailed', @@ -657,16 +795,16 @@ export const delete2 = oc .output(zDeleteAgentByAgentIdSkillsBySlugResponse) export const bySlug = { - delete: delete2, + delete: delete3, inferTools, } -export const skills = { +export const skills2 = { upload: upload2, bySlug, } -export const get16 = oc +export const get20 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -683,14 +821,29 @@ export const get16 = oc .output(zGetAgentByAgentIdStatisticsSummaryResponse) export const summary = { - get: get16, + get: get20, } export const statistics = { summary, } -export const get17 = oc +export const post12 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdVersionsByVersionIdRestore', + path: '/agent/{agent_id}/versions/{version_id}/restore', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdVersionsByVersionIdRestorePath })) + .output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse) + +export const restore = { + post: post12, +} + +export const get21 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -702,10 +855,11 @@ export const get17 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get17, + get: get21, + restore, } -export const get18 = oc +export const get22 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -717,11 +871,11 @@ export const get18 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get18, + get: get22, byVersionId, } -export const delete3 = oc +export const delete4 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -733,7 +887,7 @@ export const delete3 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get19 = oc +export const get23 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -756,9 +910,12 @@ export const put2 = oc .output(zPutAgentByAgentIdResponse) export const byAgentId = { - delete: delete3, - get: get19, + delete: delete4, + get: get23, put: put2, + apiAccess, + apiEnable, + apiKeys, chatMessages, composer, copy, @@ -771,12 +928,12 @@ export const byAgentId = { messages: messages2, referencingWorkflows, sandbox, - skills, + skills: skills2, statistics, versions, } -export const get20 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -787,7 +944,7 @@ export const get20 = oc .input(z.object({ query: zGetAgentQuery.optional() })) .output(zGetAgentResponse) -export const post10 = oc +export const post13 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -800,8 +957,8 @@ export const post10 = oc .output(zPostAgentResponse) export const agent = { - get: get20, - post: post10, + get: get24, + post: post13, inviteOptions, byAgentId, } diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 64afc442406..837b267a636 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -29,6 +29,7 @@ export type AgentAppDetailWithSite = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null deleted_tools?: Array description?: string | null enable_api: boolean @@ -73,6 +74,39 @@ export type AgentAppUpdatePayload = { use_icon_as_answer_icon?: boolean | null } +export type AgentApiAccessResponse = { + api_key_count: number + api_rph: number + api_rpm: number + chat_endpoint: string + conversations_endpoint: string + enabled: boolean + files_upload_endpoint: string + info_endpoint: string + messages_endpoint: string + meta_endpoint: string + parameters_endpoint: string + service_api_base_url: string + stop_endpoint: string + streaming_only?: boolean +} + +export type AgentApiStatusPayload = { + enable_api: boolean +} + +export type ApiKeyList = { + data: Array +} + +export type ApiKeyItem = { + created_at?: number | null + id: string + last_used_at?: number | null + token: string + type: string +} + export type MessageInfiniteScrollPaginationResponse = { data: Array has_more: boolean @@ -148,6 +182,29 @@ export type AgentDrivePreviewResponse = { truncated: boolean } +export type AgentDriveSkillListResponse = { + items?: Array +} + +export type AgentDriveSkillInspectResponse = { + archive_key?: string | null + created_at?: number | null + description: string + file_tree?: Array<{ + [key: string]: unknown + }> + files?: Array + hash?: string | null + mime_type?: string | null + name: string + path: string + size?: number | null + skill_md: AgentDriveSkillMarkdownResponse + skill_md_key: string + source: string + warnings?: Array +} + export type AgentAppFeaturesPayload = { opening_statement?: string | null retriever_resource?: AgentFeatureToggleConfig | null @@ -292,6 +349,11 @@ export type AgentConfigSnapshotDetailResponse = { version_note?: string | null } +export type AgentConfigSnapshotRestoreResponse = { + active_config_snapshot_id: string + result: 'success' +} + export type AgentAppPartial = { access_mode?: string | null active_config_is_published?: boolean @@ -301,6 +363,7 @@ export type AgentAppPartial = { create_user_name?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null icon?: string | null @@ -530,9 +593,39 @@ export type AgentDriveItemResponse = { created_at?: number | null file_kind: string hash?: string | null + is_skill?: boolean | null key: string mime_type?: string | null size?: number | null + skill_metadata?: string | null +} + +export type AgentDriveSkillItemResponse = { + archive_key?: string | null + created_at?: number | null + description: string + hash?: string | null + mime_type?: string | null + name: string + path: string + size?: number | null + skill_md_key: string +} + +export type AgentDriveSkillFileResponse = { + available_in_drive: boolean + drive_key?: string | null + name: string + path: string + type: string +} + +export type AgentDriveSkillMarkdownResponse = { + binary: boolean + key: string + size?: number | null + text?: string | null + truncated: boolean } export type AgentFeatureToggleConfig = { @@ -1123,6 +1216,7 @@ export type AgentUserSatisfactionRateStatisticResponse = { export type AgentConfigRevisionOperation = | 'create_version' + | 'restore_version' | 'save_current_version' | 'save_new_agent' | 'save_new_version' @@ -1442,6 +1536,7 @@ export type AgentAppDetailWithSiteWritable = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null deleted_tools?: Array description?: string | null enable_api: boolean @@ -1475,6 +1570,7 @@ export type AgentAppPartialWritable = { create_user_name?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null icon?: string | null @@ -1636,6 +1732,95 @@ export type PutAgentByAgentIdResponses = { export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses] +export type GetAgentByAgentIdApiAccessData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/api-access' +} + +export type GetAgentByAgentIdApiAccessResponses = { + 200: AgentApiAccessResponse +} + +export type GetAgentByAgentIdApiAccessResponse + = GetAgentByAgentIdApiAccessResponses[keyof GetAgentByAgentIdApiAccessResponses] + +export type PostAgentByAgentIdApiEnableData = { + body: AgentApiStatusPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/api-enable' +} + +export type PostAgentByAgentIdApiEnableErrors = { + 403: unknown +} + +export type PostAgentByAgentIdApiEnableResponses = { + 200: AgentApiAccessResponse +} + +export type PostAgentByAgentIdApiEnableResponse + = PostAgentByAgentIdApiEnableResponses[keyof PostAgentByAgentIdApiEnableResponses] + +export type GetAgentByAgentIdApiKeysData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/api-keys' +} + +export type GetAgentByAgentIdApiKeysResponses = { + 200: ApiKeyList +} + +export type GetAgentByAgentIdApiKeysResponse + = GetAgentByAgentIdApiKeysResponses[keyof GetAgentByAgentIdApiKeysResponses] + +export type PostAgentByAgentIdApiKeysData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/api-keys' +} + +export type PostAgentByAgentIdApiKeysErrors = { + 400: unknown +} + +export type PostAgentByAgentIdApiKeysResponses = { + 201: ApiKeyItem +} + +export type PostAgentByAgentIdApiKeysResponse + = PostAgentByAgentIdApiKeysResponses[keyof PostAgentByAgentIdApiKeysResponses] + +export type DeleteAgentByAgentIdApiKeysByApiKeyIdData = { + body?: never + path: { + agent_id: string + api_key_id: string + } + query?: never + url: '/agent/{agent_id}/api-keys/{api_key_id}' +} + +export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = { + 204: void +} + +export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse + = DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses] + export type GetAgentByAgentIdChatMessagesData = { body?: never path: { @@ -1837,6 +2022,39 @@ export type GetAgentByAgentIdDriveFilesPreviewResponses = { export type GetAgentByAgentIdDriveFilesPreviewResponse = GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] +export type GetAgentByAgentIdDriveSkillsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/drive/skills' +} + +export type GetAgentByAgentIdDriveSkillsResponses = { + 200: AgentDriveSkillListResponse +} + +export type GetAgentByAgentIdDriveSkillsResponse + = GetAgentByAgentIdDriveSkillsResponses[keyof GetAgentByAgentIdDriveSkillsResponses] + +export type GetAgentByAgentIdDriveSkillsBySkillPathInspectData = { + body?: never + path: { + agent_id: string + skill_path: string + } + query?: never + url: '/agent/{agent_id}/drive/skills/{skill_path}/inspect' +} + +export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses = { + 200: AgentDriveSkillInspectResponse +} + +export type GetAgentByAgentIdDriveSkillsBySkillPathInspectResponse + = GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses[keyof GetAgentByAgentIdDriveSkillsBySkillPathInspectResponses] + export type PostAgentByAgentIdFeaturesData = { body: AgentAppFeaturesPayload path: { @@ -2188,3 +2406,20 @@ export type GetAgentByAgentIdVersionsByVersionIdResponses = { export type GetAgentByAgentIdVersionsByVersionIdResponse = GetAgentByAgentIdVersionsByVersionIdResponses[keyof GetAgentByAgentIdVersionsByVersionIdResponses] + +export type PostAgentByAgentIdVersionsByVersionIdRestoreData = { + body?: never + path: { + agent_id: string + version_id: string + } + query?: never + url: '/agent/{agent_id}/versions/{version_id}/restore' +} + +export type PostAgentByAgentIdVersionsByVersionIdRestoreResponses = { + 200: AgentConfigSnapshotRestoreResponse +} + +export type PostAgentByAgentIdVersionsByVersionIdRestoreResponse + = PostAgentByAgentIdVersionsByVersionIdRestoreResponses[keyof PostAgentByAgentIdVersionsByVersionIdRestoreResponses] diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 7d6bd6f5eb2..297055a155c 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -2,6 +2,51 @@ import * as z from 'zod' +/** + * AgentApiAccessResponse + */ +export const zAgentApiAccessResponse = z.object({ + api_key_count: z.int(), + api_rph: z.int(), + api_rpm: z.int(), + chat_endpoint: z.string(), + conversations_endpoint: z.string(), + enabled: z.boolean(), + files_upload_endpoint: z.string(), + info_endpoint: z.string(), + messages_endpoint: z.string(), + meta_endpoint: z.string(), + parameters_endpoint: z.string(), + service_api_base_url: z.string(), + stop_endpoint: z.string(), + streaming_only: z.boolean().optional().default(true), +}) + +/** + * AgentApiStatusPayload + */ +export const zAgentApiStatusPayload = z.object({ + enable_api: z.boolean(), +}) + +/** + * ApiKeyItem + */ +export const zApiKeyItem = z.object({ + created_at: z.int().nullish(), + id: z.string(), + last_used_at: z.int().nullish(), + token: z.string(), + type: z.string(), +}) + +/** + * ApiKeyList + */ +export const zApiKeyList = z.object({ + data: z.array(zApiKeyItem), +}) + /** * SuggestedQuestionsResponse */ @@ -78,6 +123,14 @@ export const zAgentSandboxUploadPayload = z.object({ path: z.string().min(1), }) +/** + * AgentConfigSnapshotRestoreResponse + */ +export const zAgentConfigSnapshotRestoreResponse = z.object({ + active_config_snapshot_id: z.string(), + result: z.literal('success'), +}) + /** * IconType */ @@ -286,9 +339,11 @@ export const zAgentDriveItemResponse = z.object({ created_at: z.int().nullish(), file_kind: z.string(), hash: z.string().nullish(), + is_skill: z.boolean().nullish(), key: z.string(), mime_type: z.string().nullish(), size: z.int().nullish(), + skill_metadata: z.string().nullish(), }) /** @@ -298,6 +353,70 @@ export const zAgentDriveListResponse = z.object({ items: z.array(zAgentDriveItemResponse).optional(), }) +/** + * AgentDriveSkillItemResponse + */ +export const zAgentDriveSkillItemResponse = z.object({ + archive_key: z.string().nullish(), + created_at: z.int().nullish(), + description: z.string(), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string(), + path: z.string(), + size: z.int().nullish(), + skill_md_key: z.string(), +}) + +/** + * AgentDriveSkillListResponse + */ +export const zAgentDriveSkillListResponse = z.object({ + items: z.array(zAgentDriveSkillItemResponse).optional(), +}) + +/** + * AgentDriveSkillFileResponse + */ +export const zAgentDriveSkillFileResponse = z.object({ + available_in_drive: z.boolean(), + drive_key: z.string().nullish(), + name: z.string(), + path: z.string(), + type: z.string(), +}) + +/** + * AgentDriveSkillMarkdownResponse + */ +export const zAgentDriveSkillMarkdownResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentDriveSkillInspectResponse + */ +export const zAgentDriveSkillInspectResponse = z.object({ + archive_key: z.string().nullish(), + created_at: z.int().nullish(), + description: z.string(), + file_tree: z.array(z.record(z.string(), z.unknown())).optional(), + files: z.array(zAgentDriveSkillFileResponse).optional(), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string(), + path: z.string(), + size: z.int().nullish(), + skill_md: zAgentDriveSkillMarkdownResponse, + skill_md_key: z.string(), + source: z.string(), + warnings: z.array(z.string()).optional(), +}) + /** * AgentFeatureToggleConfig */ @@ -610,6 +729,7 @@ export const zAgentAppPartial = z.object({ create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), @@ -673,6 +793,7 @@ export const zAgentAppDetailWithSite = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(), @@ -1117,6 +1238,7 @@ export const zAgentStatisticSummaryEnvelopeResponse = z.object({ */ export const zAgentConfigRevisionOperation = z.enum([ 'create_version', + 'restore_version', 'save_current_version', 'save_new_agent', 'save_new_version', @@ -2011,6 +2133,7 @@ export const zAgentAppPartialWritable = z.object({ create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), @@ -2075,6 +2198,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(), @@ -2178,6 +2302,54 @@ export const zPutAgentByAgentIdPath = z.object({ */ export const zPutAgentByAgentIdResponse = zAgentAppDetailWithSite +export const zGetAgentByAgentIdApiAccessPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent service API access + */ +export const zGetAgentByAgentIdApiAccessResponse = zAgentApiAccessResponse + +export const zPostAgentByAgentIdApiEnableBody = zAgentApiStatusPayload + +export const zPostAgentByAgentIdApiEnablePath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent service API status updated + */ +export const zPostAgentByAgentIdApiEnableResponse = zAgentApiAccessResponse + +export const zGetAgentByAgentIdApiKeysPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent service API keys + */ +export const zGetAgentByAgentIdApiKeysResponse = zApiKeyList + +export const zPostAgentByAgentIdApiKeysPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent service API key created + */ +export const zPostAgentByAgentIdApiKeysResponse = zApiKeyItem + +export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({ + agent_id: z.uuid(), + api_key_id: z.uuid(), +}) + +/** + * Agent service API key deleted + */ +export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() + export const zGetAgentByAgentIdChatMessagesPath = z.object({ agent_id: z.uuid(), }) @@ -2304,6 +2476,26 @@ export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({ */ export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewResponse +export const zGetAgentByAgentIdDriveSkillsPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Drive skills + */ +export const zGetAgentByAgentIdDriveSkillsResponse = zAgentDriveSkillListResponse + +export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectPath = z.object({ + agent_id: z.uuid(), + skill_path: z.string(), +}) + +/** + * Drive skill inspect view + */ +export const zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse + = zAgentDriveSkillInspectResponse + export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload export const zPostAgentByAgentIdFeaturesPath = z.object({ @@ -2530,3 +2722,14 @@ export const zGetAgentByAgentIdVersionsByVersionIdPath = z.object({ * Agent version detail */ export const zGetAgentByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse + +export const zPostAgentByAgentIdVersionsByVersionIdRestorePath = z.object({ + agent_id: z.uuid(), + version_id: z.uuid(), +}) + +/** + * Agent version restored + */ +export const zPostAgentByAgentIdVersionsByVersionIdRestoreResponse + = zAgentConfigSnapshotRestoreResponse diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index eab3c17eb43..f24407a17d4 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -54,6 +54,12 @@ import { zGetAppsByAppIdAgentDriveFilesPreviewResponse, zGetAppsByAppIdAgentDriveFilesQuery, zGetAppsByAppIdAgentDriveFilesResponse, + zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath, + zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery, + zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse, + zGetAppsByAppIdAgentDriveSkillsPath, + zGetAppsByAppIdAgentDriveSkillsQuery, + zGetAppsByAppIdAgentDriveSkillsResponse, zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, @@ -861,8 +867,62 @@ export const files = { preview: preview2, } +/** + * Inspect one drive-backed skill for slash-menu hover/detail UI + */ +export const get8 = oc + .route({ + description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentDriveSkillsBySkillPathInspect', + path: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath, + query: zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse) + +export const inspect = { + get: get8, +} + +export const bySkillPath = { + inspect, +} + +/** + * List drive-backed skills for the bound agent + */ +export const get9 = oc + .route({ + description: 'List drive-backed skills for the bound agent', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentDriveSkills', + path: '/apps/{app_id}/agent/drive/skills', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveSkillsPath, + query: zGetAppsByAppIdAgentDriveSkillsQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentDriveSkillsResponse) + +export const skills = { + get: get9, + bySkillPath, +} + export const drive = { files, + skills, } /** @@ -920,7 +980,7 @@ export const files2 = { * * Get agent execution logs for an application */ -export const get8 = oc +export const get10 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -934,7 +994,7 @@ export const get8 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get8, + get: get10, } /** @@ -1021,7 +1081,7 @@ export const bySlug = { inferTools, } -export const skills = { +export const skills2 = { upload, bySlug, } @@ -1030,13 +1090,13 @@ export const agent = { drive, files: files2, logs, - skills, + skills: skills2, } /** * Get status of annotation reply action job */ -export const get9 = oc +export const get11 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1049,7 +1109,7 @@ export const get9 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get9, + get: get11, } export const status = { @@ -1088,7 +1148,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get10 = oc +export const get12 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1101,7 +1161,7 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get10, + get: get12, } /** @@ -1154,7 +1214,7 @@ export const batchImport = { /** * Get status of batch import job */ -export const get11 = oc +export const get13 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1167,7 +1227,7 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get11, + get: get13, } export const batchImportStatus = { @@ -1177,7 +1237,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get12 = oc +export const get14 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1190,13 +1250,13 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get12, + get: get14, } /** * Export all annotations for an app with CSV injection protection */ -export const get13 = oc +export const get15 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1209,13 +1269,13 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get13, + get: get15, } /** * Get hit histories for an annotation */ -export const get14 = oc +export const get16 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1233,7 +1293,7 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get14, + get: get16, } export const delete3 = oc @@ -1289,7 +1349,7 @@ export const delete4 = oc /** * Get annotations for an app with pagination */ -export const get15 = oc +export const get17 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1326,7 +1386,7 @@ export const post16 = oc export const annotations = { delete: delete4, - get: get15, + get: get17, post: post16, batchImport, batchImportStatus, @@ -1392,7 +1452,7 @@ export const delete5 = oc /** * Get chat conversation details */ -export const get16 = oc +export const get18 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1406,13 +1466,13 @@ export const get16 = oc export const byConversationId = { delete: delete5, - get: get16, + get: get18, } /** * Get chat conversations with pagination, filtering and summary */ -export const get17 = oc +export const get19 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1430,14 +1490,14 @@ export const get17 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get17, + get: get19, byConversationId, } /** * Get suggested questions for a message */ -export const get18 = oc +export const get20 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1450,7 +1510,7 @@ export const get18 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get18, + get: get20, } export const byMessageId = { @@ -1483,7 +1543,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get19 = oc +export const get21 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1498,7 +1558,7 @@ export const get19 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get19, + get: get21, byMessageId, byTaskId, } @@ -1522,7 +1582,7 @@ export const delete6 = oc /** * Get completion conversation details with messages */ -export const get20 = oc +export const get22 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1536,13 +1596,13 @@ export const get20 = oc export const byConversationId2 = { delete: delete6, - get: get20, + get: get22, } /** * Get completion conversations with pagination and filtering */ -export const get21 = oc +export const get23 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1560,7 +1620,7 @@ export const get21 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get21, + get: get23, byConversationId: byConversationId2, } @@ -1615,7 +1675,7 @@ export const completionMessages = { /** * Get conversation variables for an application */ -export const get22 = oc +export const get24 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1633,7 +1693,7 @@ export const get22 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get22, + get: get24, } /** @@ -1694,7 +1754,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get23 = oc +export const get25 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1710,13 +1770,13 @@ export const get23 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get23, + get: get25, } /** * Export user feedback data for Google Sheets */ -export const get24 = oc +export const get26 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -1734,7 +1794,7 @@ export const get24 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get24, + get: get26, } /** @@ -1779,7 +1839,7 @@ export const icon = { /** * Get message details by ID */ -export const get25 = oc +export const get27 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -1792,7 +1852,7 @@ export const get25 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get25, + get: get27, } export const messages = { @@ -1864,7 +1924,7 @@ export const publishToCreatorsPlatform = { /** * Get MCP server configuration for an application */ -export const get26 = oc +export const get28 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -1908,7 +1968,7 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get26, + get: get28, post: post29, put, } @@ -2009,7 +2069,7 @@ export const star = { /** * Get average response time statistics for an application */ -export const get27 = oc +export const get29 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2027,13 +2087,13 @@ export const get27 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get27, + get: get29, } /** * Get average session interaction statistics for an application */ -export const get28 = oc +export const get30 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2051,13 +2111,13 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get28, + get: get30, } /** * Get daily conversation statistics for an application */ -export const get29 = oc +export const get31 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2075,13 +2135,13 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get29, + get: get31, } /** * Get daily terminal/end-user statistics for an application */ -export const get30 = oc +export const get32 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2099,13 +2159,13 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get30, + get: get32, } /** * Get daily message statistics for an application */ -export const get31 = oc +export const get33 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2123,13 +2183,13 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get31, + get: get33, } /** * Get daily token cost statistics for an application */ -export const get32 = oc +export const get34 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2147,13 +2207,13 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get32, + get: get34, } /** * Get tokens per second statistics for an application */ -export const get33 = oc +export const get35 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2171,13 +2231,13 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get33, + get: get35, } /** * Get user satisfaction rate statistics for an application */ -export const get34 = oc +export const get36 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2195,7 +2255,7 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get34, + get: get36, } export const statistics = { @@ -2212,7 +2272,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get35 = oc +export const get37 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2230,7 +2290,7 @@ export const get35 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get35, + get: get37, } /** @@ -2260,7 +2320,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get36 = oc +export const get38 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2289,7 +2349,7 @@ export const post35 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get36, + get: get38, post: post35, } @@ -2320,7 +2380,7 @@ export const delete8 = oc /** * Get tracing configuration for an application */ -export const get37 = oc +export const get39 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2377,7 +2437,7 @@ export const post36 = oc export const traceConfig = { delete: delete8, - get: get37, + get: get39, patch, post: post36, } @@ -2409,7 +2469,7 @@ export const triggerEnable = { /** * Get app triggers list */ -export const get38 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2422,7 +2482,7 @@ export const get38 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get38, + get: get40, } /** @@ -2430,7 +2490,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get39 = oc +export const get41 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2449,7 +2509,7 @@ export const get39 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get39, + get: get41, } /** @@ -2457,7 +2517,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get40 = oc +export const get42 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2476,7 +2536,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get40, + get: get42, } /** @@ -2484,7 +2544,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get41 = oc +export const get43 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2503,7 +2563,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get41, + get: get43, } /** @@ -2539,7 +2599,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get42 = oc +export const get44 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2552,7 +2612,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get42, + get: get44, } /** @@ -2560,7 +2620,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get43 = oc +export const get45 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2574,7 +2634,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get43, + get: get45, } /** @@ -2582,7 +2642,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get44 = oc +export const get46 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2596,7 +2656,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get44, + get: get46, export: export4, nodeExecutions, } @@ -2604,7 +2664,7 @@ export const byRunId = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get45 = oc +export const get47 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2622,7 +2682,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read = { - get: get45, + get: get47, } /** @@ -2652,7 +2712,7 @@ export const upload2 = { /** * List a directory in a workflow Agent node sandbox */ -export const get46 = oc +export const get48 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2671,7 +2731,7 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) export const files3 = { - get: get46, + get: get48, read, upload: upload2, } @@ -2697,7 +2757,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get47 = oc +export const get49 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2716,7 +2776,7 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get47, + get: get49, count: count3, tasks, byRunId, @@ -2728,7 +2788,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get48 = oc +export const get50 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2742,7 +2802,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get48, + get: get50, } /** @@ -2867,7 +2927,7 @@ export const delete10 = oc * * Get a specific workflow comment */ -export const get49 = oc +export const get51 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2905,7 +2965,7 @@ export const put3 = oc export const byCommentId = { delete: delete10, - get: get49, + get: get51, put: put3, replies, resolve, @@ -2916,7 +2976,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get50 = oc +export const get52 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2954,7 +3014,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get50, + get: get52, post: post42, mentionUsers, byCommentId, @@ -2963,7 +3023,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get51 = oc +export const get53 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -2981,13 +3041,13 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get51, + get: get53, } /** * Get workflow daily runs statistics */ -export const get52 = oc +export const get54 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3005,13 +3065,13 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get52, + get: get54, } /** * Get workflow daily terminals statistics */ -export const get53 = oc +export const get55 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3029,13 +3089,13 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get53, + get: get55, } /** * Get workflow daily token cost statistics */ -export const get54 = oc +export const get56 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3053,7 +3113,7 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get54, + get: get56, } export const statistics2 = { @@ -3073,7 +3133,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get55 = oc +export const get57 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3092,7 +3152,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get55, + get: get57, } /** @@ -3100,7 +3160,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get56 = oc +export const get58 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3114,14 +3174,14 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get56, + get: get58, byBlockType, } /** * Get conversation variables for workflow */ -export const get57 = oc +export const get59 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3154,7 +3214,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get57, + get: get59, post: post43, } @@ -3163,7 +3223,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get58 = oc +export const get60 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3197,7 +3257,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get58, + get: get60, post: post44, } @@ -3402,7 +3462,7 @@ export const loop2 = { nodes: nodes6, } -export const get59 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3416,7 +3476,7 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get59, + get: get61, } export const post51 = oc @@ -3479,7 +3539,7 @@ export const validate = { post: post53, } -export const get60 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3507,7 +3567,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get60, + get: get62, put: put4, candidates, impact, @@ -3518,7 +3578,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get61 = oc +export const get63 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3531,7 +3591,7 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get61, + get: get63, } /** @@ -3606,7 +3666,7 @@ export const delete11 = oc /** * Get variables for a specific node */ -export const get62 = oc +export const get64 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3620,7 +3680,7 @@ export const get62 = oc export const variables = { delete: delete11, - get: get62, + get: get64, } export const byNodeId8 = { @@ -3665,7 +3725,7 @@ export const run10 = { /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get63 = oc +export const get65 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3678,13 +3738,13 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get63, + get: get65, } /** * Full value for one declared output, including signed download URL for files. */ -export const get64 = oc +export const get66 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3701,7 +3761,7 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview4 = { - get: get64, + get: get66, } export const byOutputName = { @@ -3711,7 +3771,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get65 = oc +export const get67 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3724,14 +3784,14 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get65, + get: get67, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get66 = oc +export const get68 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3744,7 +3804,7 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get66, + get: get68, events, byNodeId: byNodeId9, } @@ -3760,7 +3820,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get67 = oc +export const get69 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3773,7 +3833,7 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get67, + get: get69, } /** @@ -3873,7 +3933,7 @@ export const delete12 = oc /** * Get a specific workflow variable */ -export const get68 = oc +export const get70 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -3907,7 +3967,7 @@ export const patch2 = oc export const byVariableId = { delete: delete12, - get: get68, + get: get70, patch: patch2, reset, } @@ -3933,7 +3993,7 @@ export const delete13 = oc * * Get draft workflow variables */ -export const get69 = oc +export const get71 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -3953,7 +4013,7 @@ export const get69 = oc export const variables2 = { delete: delete13, - get: get69, + get: get71, byVariableId, } @@ -3962,7 +4022,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get70 = oc +export const get72 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -3999,7 +4059,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get70, + get: get72, post: post59, conversationVariables: conversationVariables2, environmentVariables, @@ -4020,7 +4080,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get71 = oc +export const get73 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4054,14 +4114,14 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get71, + get: get73, post: post60, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get72 = oc +export const get74 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4074,13 +4134,13 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get72, + get: get74, } /** * Full value for one declared output of a published run. */ -export const get73 = oc +export const get75 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4101,7 +4161,7 @@ export const get73 = oc ) export const preview5 = { - get: get73, + get: get75, } export const byOutputName2 = { @@ -4111,7 +4171,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get74 = oc +export const get76 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4124,14 +4184,14 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get74, + get: get76, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get75 = oc +export const get77 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4144,7 +4204,7 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get75, + get: get77, events: events2, byNodeId: byNodeId10, } @@ -4164,7 +4224,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get76 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4182,7 +4242,7 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get76, + get: get78, } export const triggers2 = { @@ -4258,7 +4318,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get77 = oc +export const get79 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4277,7 +4337,7 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get77, + get: get79, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4310,7 +4370,7 @@ export const delete15 = oc * * Get application details */ -export const get78 = oc +export const get80 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4343,7 +4403,7 @@ export const put6 = oc export const byAppId2 = { delete: delete15, - get: get78, + get: get80, put: put6, advancedChat, agent, @@ -4412,7 +4472,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get79 = oc +export const get81 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4445,7 +4505,7 @@ export const post62 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get79, + get: get81, post: post62, byApiKeyId, } @@ -4457,7 +4517,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get80 = oc +export const get82 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4470,7 +4530,7 @@ export const get80 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get80, + get: get82, } export const server2 = { @@ -4486,7 +4546,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get81 = oc +export const get83 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4519,7 +4579,7 @@ export const post63 = oc .output(zPostAppsResponse) export const apps = { - get: get81, + get: get83, post: post63, imports, starred, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 904252c77eb..a3ab6a37c56 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -193,6 +193,29 @@ export type AgentDrivePreviewResponse = { truncated: boolean } +export type AgentDriveSkillListResponse = { + items?: Array +} + +export type AgentDriveSkillInspectResponse = { + archive_key?: string | null + created_at?: number | null + description: string + file_tree?: Array<{ + [key: string]: unknown + }> + files?: Array + hash?: string | null + mime_type?: string | null + name: string + path: string + size?: number | null + skill_md: AgentDriveSkillMarkdownResponse + skill_md_key: string + source: string + warnings?: Array +} + export type AgentDriveDeleteResponse = { config_version_id?: string | null removed_keys?: Array @@ -1274,9 +1297,39 @@ export type AgentDriveItemResponse = { created_at?: number | null file_kind: string hash?: string | null + is_skill?: boolean | null key: string mime_type?: string | null size?: number | null + skill_metadata?: string | null +} + +export type AgentDriveSkillItemResponse = { + archive_key?: string | null + created_at?: number | null + description: string + hash?: string | null + mime_type?: string | null + name: string + path: string + size?: number | null + skill_md_key: string +} + +export type AgentDriveSkillFileResponse = { + available_in_drive: boolean + drive_key?: string | null + name: string + path: string + type: string +} + +export type AgentDriveSkillMarkdownResponse = { + binary: boolean + key: string + size?: number | null + text?: string | null + truncated: boolean } export type AgentDriveFileResponse = { @@ -3125,6 +3178,44 @@ export type GetAppsByAppIdAgentDriveFilesPreviewResponses = { export type GetAppsByAppIdAgentDriveFilesPreviewResponse = GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses] +export type GetAppsByAppIdAgentDriveSkillsData = { + body?: never + path: { + app_id: string + } + query?: { + node_id?: string + prefix?: string + } + url: '/apps/{app_id}/agent/drive/skills' +} + +export type GetAppsByAppIdAgentDriveSkillsResponses = { + 200: AgentDriveSkillListResponse +} + +export type GetAppsByAppIdAgentDriveSkillsResponse + = GetAppsByAppIdAgentDriveSkillsResponses[keyof GetAppsByAppIdAgentDriveSkillsResponses] + +export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectData = { + body?: never + path: { + app_id: string + skill_path: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/drive/skills/{skill_path}/inspect' +} + +export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses = { + 200: AgentDriveSkillInspectResponse +} + +export type GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse + = GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses[keyof GetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponses] + export type DeleteAppsByAppIdAgentFilesData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 4a6f397bcbd..be116b5c795 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -918,9 +918,11 @@ export const zAgentDriveItemResponse = z.object({ created_at: z.int().nullish(), file_kind: z.string(), hash: z.string().nullish(), + is_skill: z.boolean().nullish(), key: z.string(), mime_type: z.string().nullish(), size: z.int().nullish(), + skill_metadata: z.string().nullish(), }) /** @@ -930,6 +932,70 @@ export const zAgentDriveListResponse = z.object({ items: z.array(zAgentDriveItemResponse).optional(), }) +/** + * AgentDriveSkillItemResponse + */ +export const zAgentDriveSkillItemResponse = z.object({ + archive_key: z.string().nullish(), + created_at: z.int().nullish(), + description: z.string(), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string(), + path: z.string(), + size: z.int().nullish(), + skill_md_key: z.string(), +}) + +/** + * AgentDriveSkillListResponse + */ +export const zAgentDriveSkillListResponse = z.object({ + items: z.array(zAgentDriveSkillItemResponse).optional(), +}) + +/** + * AgentDriveSkillFileResponse + */ +export const zAgentDriveSkillFileResponse = z.object({ + available_in_drive: z.boolean(), + drive_key: z.string().nullish(), + name: z.string(), + path: z.string(), + type: z.string(), +}) + +/** + * AgentDriveSkillMarkdownResponse + */ +export const zAgentDriveSkillMarkdownResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentDriveSkillInspectResponse + */ +export const zAgentDriveSkillInspectResponse = z.object({ + archive_key: z.string().nullish(), + created_at: z.int().nullish(), + description: z.string(), + file_tree: z.array(z.record(z.string(), z.unknown())).optional(), + files: z.array(zAgentDriveSkillFileResponse).optional(), + hash: z.string().nullish(), + mime_type: z.string().nullish(), + name: z.string(), + path: z.string(), + size: z.int().nullish(), + skill_md: zAgentDriveSkillMarkdownResponse, + skill_md_key: z.string(), + source: z.string(), + warnings: z.array(z.string()).optional(), +}) + /** * AgentDriveFileResponse */ @@ -3916,6 +3982,35 @@ export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ */ export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse +export const zGetAppsByAppIdAgentDriveSkillsPath = z.object({ + app_id: z.uuid(), +}) + +export const zGetAppsByAppIdAgentDriveSkillsQuery = z.object({ + node_id: z.string().optional(), + prefix: z.string().optional().default(''), +}) + +/** + * Drive skills + */ +export const zGetAppsByAppIdAgentDriveSkillsResponse = zAgentDriveSkillListResponse + +export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectPath = z.object({ + app_id: z.uuid(), + skill_path: z.string(), +}) + +export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * Drive skill inspect view + */ +export const zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse + = zAgentDriveSkillInspectResponse + export const zDeleteAppsByAppIdAgentFilesPath = z.object({ app_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index bc7cbea340b..47aa1b90d6a 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -30,6 +30,9 @@ import { zGetHealthResponse, zGetOauthDeviceLookupQuery, zGetOauthDeviceLookupResponse, + zGetPermittedExternalAppsByAppIdDescribePath, + zGetPermittedExternalAppsByAppIdDescribeQuery, + zGetPermittedExternalAppsByAppIdDescribeResponse, zGetPermittedExternalAppsQuery, zGetPermittedExternalAppsResponse, zGetVersionResponse, @@ -450,6 +453,30 @@ export const oauth = { } export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getPermittedExternalAppsByAppIdDescribe', + path: '/permitted-external-apps/{app_id}/describe', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetPermittedExternalAppsByAppIdDescribePath, + query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(), + }), + ) + .output(zGetPermittedExternalAppsByAppIdDescribeResponse) + +export const describe2 = { + get: get12, +} + +export const byAppId2 = { + describe: describe2, +} + +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -461,7 +488,8 @@ export const get12 = oc .output(zGetPermittedExternalAppsResponse) export const permittedExternalApps = { - get: get12, + get: get13, + byAppId: byAppId2, } export const post9 = oc @@ -544,7 +572,7 @@ export const byMemberId = { role, } -export const get13 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -578,7 +606,7 @@ export const post11 = oc .output(zPostWorkspacesByWorkspaceIdMembersResponse) export const members = { - get: get13, + get: get14, post: post11, byMemberId, } @@ -598,7 +626,7 @@ export const switch_ = { post: post12, } -export const get14 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -610,13 +638,13 @@ export const get14 = oc .output(zGetWorkspacesByWorkspaceIdResponse) export const byWorkspaceId = { - get: get14, + get: get15, apps: apps2, members, switch: switch_, } -export const get15 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -627,7 +655,7 @@ export const get15 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get15, + get: get16, byWorkspaceId, } diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index e1217f3f6d7..185ee37aa6f 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -20,14 +20,12 @@ export type AccountResponse = { } export type AppDescribeInfo = { - author?: string | null description?: string | null id: string is_agent?: boolean mode: string name: string service_api_enabled: boolean - tags?: Array updated_at?: string | null } @@ -66,13 +64,11 @@ export type AppDslImportPayload = { yaml_url?: string | null } -export type AppInfoResponse = { - author?: string | null +export type AppInfo = { description?: string | null id: string mode: string name: string - tags?: Array } export type AppListQuery = { @@ -80,7 +76,6 @@ export type AppListQuery = { mode?: AppMode | null name?: string | null page?: number - tag?: string | null workspace_id: string } @@ -93,12 +88,10 @@ export type AppListResponse = { } export type AppListRow = { - created_by_name?: string | null description?: string | null id: string mode: AppMode name: string - tags?: Array updated_at?: string | null workspace_id?: string | null workspace_name?: string | null @@ -332,6 +325,7 @@ export type OpenApiErrorCode | 'file_too_large' | 'filename_not_exists' | 'forbidden' + | 'form_not_found' | 'internal_server_error' | 'invalid_param' | 'member_license_exceeded' @@ -344,6 +338,7 @@ export type OpenApiErrorCode | 'provider_not_initialize' | 'provider_quota_exceeded' | 'rate_limit_error' + | 'recipient_surface_mismatch' | 'request_entity_too_large' | 'too_many_files' | 'too_many_requests' @@ -410,10 +405,6 @@ export type SessionRow = { prefix: string } -export type TagItem = { - name: string -} - export type TaskStopResponse = { result: 'success' } @@ -609,7 +600,6 @@ export type GetAppsData = { | 'workflow' name?: string page?: number - tag?: string workspace_id: string } url: '/apps' @@ -945,6 +935,32 @@ export type GetPermittedExternalAppsResponses = { export type GetPermittedExternalAppsResponse = GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses] +export type GetPermittedExternalAppsByAppIdDescribeData = { + body?: never + path: { + app_id: string + } + query?: { + fields?: string + } + url: '/permitted-external-apps/{app_id}/describe' +} + +export type GetPermittedExternalAppsByAppIdDescribeErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetPermittedExternalAppsByAppIdDescribeError + = GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors] + +export type GetPermittedExternalAppsByAppIdDescribeResponses = { + 200: AppDescribeResponse +} + +export type GetPermittedExternalAppsByAppIdDescribeResponse + = GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses] + export type GetWorkspacesData = { body?: never path?: never diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 51a3cb8f480..804c75394f6 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -11,6 +11,19 @@ export const zAccountPayload = z.object({ name: z.string(), }) +/** + * AppDescribeInfo + */ +export const zAppDescribeInfo = z.object({ + description: z.string().nullish(), + id: z.string(), + is_agent: z.boolean().optional().default(false), + mode: z.string(), + name: z.string(), + service_api_enabled: z.boolean(), + updated_at: z.string().nullish(), +}) + /** * AppDescribeQuery * @@ -22,6 +35,15 @@ export const zAppDescribeQuery = z.object({ fields: z.string().optional(), }) +/** + * AppDescribeResponse + */ +export const zAppDescribeResponse = z.object({ + info: zAppDescribeInfo.nullish(), + input_schema: z.record(z.string(), z.unknown()).nullish(), + parameters: z.record(z.string(), z.unknown()).nullish(), +}) + /** * AppDslExportQuery * @@ -58,6 +80,16 @@ export const zAppDslImportPayload = z.object({ yaml_url: z.string().nullish(), }) +/** + * AppInfo + */ +export const zAppInfo = z.object({ + description: z.string().nullish(), + id: z.string(), + mode: z.string(), + name: z.string(), +}) + /** * AppMode */ @@ -82,10 +114,33 @@ export const zAppListQuery = z.object({ mode: zAppMode.nullish(), name: z.string().max(200).nullish(), page: z.int().gte(1).optional().default(1), - tag: z.string().max(100).nullish(), workspace_id: z.string(), }) +/** + * AppListRow + */ +export const zAppListRow = z.object({ + description: z.string().nullish(), + id: z.string(), + mode: zAppMode, + name: z.string(), + updated_at: z.string().nullish(), + workspace_id: z.string().nullish(), + workspace_name: z.string().nullish(), +}) + +/** + * AppListResponse + */ +export const zAppListResponse = z.object({ + data: z.array(zAppListRow), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AppRunRequest */ @@ -366,6 +421,7 @@ export const zOpenApiErrorCode = z.enum([ 'file_too_large', 'filename_not_exists', 'forbidden', + 'form_not_found', 'internal_server_error', 'invalid_param', 'member_license_exceeded', @@ -378,6 +434,7 @@ export const zOpenApiErrorCode = z.enum([ 'provider_not_initialize', 'provider_quota_exceeded', 'rate_limit_error', + 'recipient_surface_mismatch', 'request_entity_too_large', 'too_many_files', 'too_many_requests', @@ -407,6 +464,17 @@ export const zPermittedExternalAppsListQuery = z.object({ page: z.int().gte(1).optional().default(1), }) +/** + * PermittedExternalAppsListResponse + */ +export const zPermittedExternalAppsListResponse = z.object({ + data: z.array(zAppListRow), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * RevokeResponse */ @@ -458,86 +526,6 @@ export const zSessionListResponse = z.object({ total: z.int(), }) -/** - * TagItem - */ -export const zTagItem = z.object({ - name: z.string(), -}) - -/** - * AppDescribeInfo - */ -export const zAppDescribeInfo = z.object({ - author: z.string().nullish(), - description: z.string().nullish(), - id: z.string(), - is_agent: z.boolean().optional().default(false), - mode: z.string(), - name: z.string(), - service_api_enabled: z.boolean(), - tags: z.array(zTagItem).optional().default([]), - updated_at: z.string().nullish(), -}) - -/** - * AppDescribeResponse - */ -export const zAppDescribeResponse = z.object({ - info: zAppDescribeInfo.nullish(), - input_schema: z.record(z.string(), z.unknown()).nullish(), - parameters: z.record(z.string(), z.unknown()).nullish(), -}) - -/** - * AppInfoResponse - */ -export const zAppInfoResponse = z.object({ - author: z.string().nullish(), - description: z.string().nullish(), - id: z.string(), - mode: z.string(), - name: z.string(), - tags: z.array(zTagItem).optional().default([]), -}) - -/** - * AppListRow - */ -export const zAppListRow = z.object({ - created_by_name: z.string().nullish(), - description: z.string().nullish(), - id: z.string(), - mode: zAppMode, - name: z.string(), - tags: z.array(zTagItem).optional().default([]), - updated_at: z.string().nullish(), - workspace_id: z.string().nullish(), - workspace_name: z.string().nullish(), -}) - -/** - * AppListResponse - */ -export const zAppListResponse = z.object({ - data: z.array(zAppListRow), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * PermittedExternalAppsListResponse - */ -export const zPermittedExternalAppsListResponse = z.object({ - data: z.array(zAppListRow), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - /** * TaskStopResponse * @@ -724,7 +712,6 @@ export const zGetAppsQuery = z.object({ .optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), - tag: z.string().max(100).optional(), workspace_id: z.string(), }) @@ -896,6 +883,19 @@ export const zGetPermittedExternalAppsQuery = z.object({ */ export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse +export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({ + app_id: z.string(), +}) + +export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({ + fields: z.string().optional(), +}) + +/** + * Permitted external app description + */ +export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse + /** * Workspace list */ diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 0d2e4d47359..9a05be1fc3c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -193,6 +193,7 @@ "@typescript/native-preview": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", "@vitest/coverage-v8": "catalog:", "class-variance-authority": "catalog:", "playwright": "catalog:", diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx index 08d622eb9ec..2fe1022d08d 100644 --- a/packages/dify-ui/src/button/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' +import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' -import { userEvent } from 'vitest/browser' import { Button } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json index 11474f08645..55080c646c8 100644 --- a/packages/dify-ui/tsconfig.json +++ b/packages/dify-ui/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@dify/tsconfig/react.json", "compilerOptions": { - "types": ["vite-plus/test/globals", "@vitest/browser/matchers"] + "types": ["vite-plus/test/globals", "vite-plus/test/matchers"] }, "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"], "exclude": ["node_modules", "dist", "storybook-static", "coverage"] diff --git a/packages/dify-ui/vitest.config.ts b/packages/dify-ui/vitest.config.ts index dfda908c563..d3ce7d88c02 100644 --- a/packages/dify-ui/vitest.config.ts +++ b/packages/dify-ui/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ '@base-ui/react/form', '@base-ui/react/merge-props', '@base-ui/react/use-render', + 'vite-plus/test/browser', ], }, test: { diff --git a/packages/jotai-tanstack-form/package.json b/packages/jotai-tanstack-form/package.json new file mode 100644 index 00000000000..5d663d00a48 --- /dev/null +++ b/packages/jotai-tanstack-form/package.json @@ -0,0 +1,30 @@ +{ + "name": "jotai-tanstack-form", + "type": "module", + "version": "0.0.0-private", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "test": "vp test", + "type-check": "tsgo" + }, + "peerDependencies": { + "@tanstack/form-core": "catalog:", + "jotai": "catalog:" + }, + "devDependencies": { + "@dify/tsconfig": "workspace:*", + "@tanstack/form-core": "catalog:", + "@typescript/native-preview": "catalog:", + "jotai": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/jotai-tanstack-form/src/index.spec.ts b/packages/jotai-tanstack-form/src/index.spec.ts new file mode 100644 index 00000000000..2af107fa324 --- /dev/null +++ b/packages/jotai-tanstack-form/src/index.spec.ts @@ -0,0 +1,306 @@ +import { createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { + atomWithForm, + createFormAtoms, +} from './index' + +type TestFormValues = { + name: string + count: number +} + +function createTestFormAtom(onSubmit = vi.fn()) { + const defaultValues: TestFormValues = { + name: '', + count: 0, + } + + return atomWithForm({ + defaultValues, + onSubmit: ({ value }) => { + onSubmit(value) + }, + }) +} + +function createSubmitValidatedFormAtom(onSubmit = vi.fn()) { + const defaultValues: TestFormValues = { + name: '', + count: 0, + } + + return atomWithForm({ + defaultValues, + validators: { + onSubmit: ({ value }) => { + if (value.name.trim()) + return undefined + + return { + fields: { + name: 'required', + }, + } + }, + }, + onSubmit: ({ value }) => { + onSubmit(value) + }, + }) +} + +function createChangeAndSubmitValidatedFormAtom(onSubmit = vi.fn()) { + const defaultValues: TestFormValues = { + name: '', + count: 0, + } + + return atomWithForm({ + defaultValues, + validators: { + onChange: ({ value }) => { + if (value.name !== 'blocked') + return undefined + + return { + fields: { + name: 'blocked', + }, + } + }, + onSubmit: ({ value }) => { + if (value.name.trim()) + return undefined + + return { + fields: { + name: 'required', + }, + } + }, + }, + onSubmit: ({ value }) => { + onSubmit(value) + }, + }) +} + +describe('jotai-tanstack-form', () => { + it('syncs a TanStack form store into Jotai atoms', () => { + const formAtom = createTestFormAtom() + const formAtoms = createFormAtoms(formAtom) + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + store.get(formAtom).api.setFieldValue('name', 'Ada') + + expect(store.get(formAtoms.stateAtom).values.name).toBe('Ada') + + unsubscribe() + }) + + it('creates scoped atoms for values, field updates, and submit', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createTestFormAtom(onSubmit)) + const countFieldAtom = formAtoms.fieldAtom('count') + const store = createStore() + + const unsubscribe = store.sub(formAtoms.valuesAtom, () => undefined) + + store.set(countFieldAtom, 3) + await store.set(formAtoms.validateAtom, 'change') + await store.set(formAtoms.submitAtom) + + expect(store.get(countFieldAtom)).toMatchObject({ + value: 3, + }) + expect(store.get(formAtoms.valuesAtom)).toEqual({ + name: '', + count: 3, + }) + expect(onSubmit).toHaveBeenCalledWith({ + name: '', + count: 3, + }) + + unsubscribe() + }) + + it('accepts FormApi options directly', async () => { + const onSubmit = vi.fn() + const formAtom = createTestFormAtom(onSubmit) + const formAtoms = createFormAtoms(formAtom) + const countFieldAtom = formAtoms.fieldAtom('count') + const store = createStore() + const unsubscribe = store.sub(formAtoms.valuesAtom, () => undefined) + + store.set(countFieldAtom, 5) + await store.set(formAtoms.submitAtom) + + expect(store.get(formAtoms.valuesAtom)).toEqual({ + name: '', + count: 5, + }) + expect(onSubmit).toHaveBeenCalledWith({ + name: '', + count: 5, + }) + + unsubscribe() + }) + + it('clears stale submit errors when a field atom updates the field value', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + + expect(store.get(nameFieldAtom).meta?.errors).toEqual(['required']) + expect(onSubmit).not.toHaveBeenCalled() + + store.set(nameFieldAtom, 'Ada') + await store.set(formAtoms.submitAtom) + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Ada', + count: 0, + }) + + unsubscribe() + }) + + it('keeps stale submit errors when a field atom update has change errors', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createChangeAndSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'blocked') + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onChange: 'blocked', + onSubmit: 'required', + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('clears stale submit errors when a field atom update only has existing blur errors', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.get(formAtoms.formAtom).api.setFieldMeta('name', prev => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onBlur: 'blurred', + }, + errorSourceMap: { + ...prev.errorSourceMap, + onBlur: 'field', + }, + })) + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onBlur: 'blurred', + onSubmit: 'required', + }) + + store.set(nameFieldAtom, 'ready') + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onBlur: 'blurred', + onSubmit: undefined, + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('keeps stale submit errors when dontUpdateMeta keeps a field untouched', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'Ada', { dontUpdateMeta: true }) + + expect(store.get(nameFieldAtom).meta).toMatchObject({ + isTouched: false, + errorMap: { + onSubmit: 'required', + }, + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('clears stale submit errors with dontUpdateMeta when the field is already touched', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + store.set(nameFieldAtom, '') + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'Ada', { dontUpdateMeta: true }) + + expect(store.get(nameFieldAtom).meta).toMatchObject({ + isTouched: true, + errorMap: { + onSubmit: undefined, + }, + }) + + unsubscribe() + }) + + it('creates and mounts form instances from atom lifecycle', () => { + const cleanup = vi.fn() + const formAtom = createTestFormAtom() + const formAtoms = createFormAtoms(formAtom) + const firstStore = createStore() + const secondStore = createStore() + const firstApi = firstStore.get(formAtom).api + const secondApi = secondStore.get(formAtom).api + const mount = vi.spyOn(firstApi, 'mount').mockReturnValue(cleanup) + + expect(firstStore.get(formAtoms.valuesAtom)).toEqual({ + name: '', + count: 0, + }) + expect(firstStore.get(formAtoms.valuesAtom)).toEqual({ + name: '', + count: 0, + }) + expect(secondStore.get(formAtoms.valuesAtom)).toEqual({ + name: '', + count: 0, + }) + expect(firstApi).not.toBe(secondApi) + + const unsubscribe = firstStore.sub(formAtoms.valuesAtom, () => undefined) + + expect(mount).toHaveBeenCalledTimes(1) + + unsubscribe() + + expect(cleanup).toHaveBeenCalledTimes(1) + mount.mockRestore() + }) +}) diff --git a/packages/jotai-tanstack-form/src/index.ts b/packages/jotai-tanstack-form/src/index.ts new file mode 100644 index 00000000000..4d5ea4bf325 --- /dev/null +++ b/packages/jotai-tanstack-form/src/index.ts @@ -0,0 +1,215 @@ +import type { + AnyFieldLikeMeta, + DeepKeys, + DeepValue, + FormAsyncValidateOrFn, + FormValidateOrFn, + UpdateMetaOptions, + Updater, + ValidationCause, +} from '@tanstack/form-core' +import type { + Atom, + WritableAtom, +} from 'jotai' +import { FormApi } from '@tanstack/form-core' +import { atom } from 'jotai' +import { atomWithLazy } from 'jotai/vanilla/utils' + +type FormValidator = FormValidateOrFn | undefined +type FormAsyncValidator = FormAsyncValidateOrFn | undefined + +export type TanStackFormApi< + TValues, + TSubmitMeta = never, +> = FormApi< + TValues, + FormValidator, + FormValidator, + FormAsyncValidator, + FormValidator, + FormAsyncValidator, + FormValidator, + FormAsyncValidator, + FormValidator, + FormAsyncValidator, + FormAsyncValidator, + TSubmitMeta +> + +export type FormState = TanStackFormApi['state'] + +type FormOptionsInput = TanStackFormApi['options'] + +export type FormFieldAtomValue = { + value: TValue + meta: AnyFieldLikeMeta | undefined +} + +export type FormFieldUpdate< + TValues, + TField extends DeepKeys = DeepKeys, +> = TField extends DeepKeys + ? { + name: TField + value: Updater> + options?: UpdateMetaOptions + } + : never + +export type FormAtomInstance = { + api: TanStackFormApi + stateAtom: Atom> +} + +export type FormAtom = Atom> + +export type FormAtoms = { + formAtom: FormAtom + stateAtom: Atom> + valuesAtom: Atom + isSubmittingAtom: Atom + setFieldAtom: WritableAtom], void> + fieldAtom: >( + name: TField, + ) => WritableAtom< + FormFieldAtomValue>, + [Updater>, UpdateMetaOptions?], + void + > + validateAtom: WritableAtom> + submitAtom: WritableAtom> +} + +function createFormInstance( + api: TanStackFormApi, +): FormAtomInstance { + const stateAtom = atom(api.state) + + stateAtom.onMount = (setFormState) => { + const mountCleanup = api.mount() + setFormState(api.state) + + const subscription = api.store.subscribe(() => { + setFormState(api.state) + }) + + return () => { + subscription.unsubscribe() + mountCleanup() + } + } + + return { + api, + stateAtom, + } +} + +function setFormFieldValue< + TValues, + TSubmitMeta, + TField extends DeepKeys, +>( + form: FormAtomInstance, + name: TField, + value: Updater>, + options?: UpdateMetaOptions, +) { + const shouldValidate = !options?.dontValidate + && !(options?.dontUpdateMeta && !form.api.getFieldMeta(name)?.isTouched) + + form.api.setFieldValue(name, value, shouldValidate ? options : { ...(options ?? {}), dontValidate: true }) + + if (!shouldValidate) + return + + const fieldMeta = form.api.getFieldMeta(name) + if (!fieldMeta?.errorMap.onSubmit) + return + + if (fieldMeta.errorMap.onChange || fieldMeta.errorMap.onDynamic) + return + + form.api.setFieldMeta(name, prev => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onSubmit: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + onSubmit: undefined, + }, + })) +} + +export function atomWithForm( + options: FormOptionsInput, +): FormAtom { + return atomWithLazy(() => createFormInstance(new FormApi(options))) +} + +export function createFormAtoms( + formAtom: FormAtom, +): FormAtoms { + const stateAtom = atom((get) => { + const form = get(formAtom) + return get(form.stateAtom) + }) + + const valuesAtom = atom((get) => { + return get(stateAtom).values + }) + + const isSubmittingAtom = atom((get) => { + return get(stateAtom).isSubmitting + }) + + const setFieldAtom = atom], void>(null, (get, _set, update) => { + setFormFieldValue(get(formAtom), update.name, update.value, update.options) + }) + + function fieldAtom>( + name: TField, + ): WritableAtom< + FormFieldAtomValue>, + [Updater>, UpdateMetaOptions?], + void + > { + return atom( + (get) => { + const form = get(formAtom) + const state = get(form.stateAtom) + const api = form.api + + return { + value: api.getFieldValue(name), + meta: state.fieldMeta[name], + } + }, + (get, _set, value, options) => { + setFormFieldValue(get(formAtom), name, value, options) + }, + ) + } + + const validateAtom = atom>(null, (get, _set, cause) => { + return get(formAtom).api.validate(cause) + }) + + const submitAtom = atom>(null, (get) => { + return get(formAtom).api.handleSubmit() + }) + + return { + formAtom, + stateAtom, + valuesAtom, + isSubmittingAtom, + setFieldAtom, + fieldAtom, + validateAtom, + submitAtom, + } +} diff --git a/packages/jotai-tanstack-form/tsconfig.json b/packages/jotai-tanstack-form/tsconfig.json new file mode 100644 index 00000000000..f00b52991ea --- /dev/null +++ b/packages/jotai-tanstack-form/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@dify/tsconfig/base.json", + "compilerOptions": { + "types": ["vite-plus/test/globals", "vite-plus/test/matchers"] + }, + "include": ["src/**/*.ts", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/jotai-tanstack-form/vite.config.ts b/packages/jotai-tanstack-form/vite.config.ts new file mode 100644 index 00000000000..c329426d536 --- /dev/null +++ b/packages/jotai-tanstack-form/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite-plus' + +export default defineConfig({ + test: { + environment: 'node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f5f3f59363..72aab2bf8b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ catalogs: specifier: 0.98.2 version: 0.98.2 '@hono/node-server': - specifier: 2.0.4 - version: 2.0.4 + specifier: 2.0.5 + version: 2.0.5 '@iconify-json/heroicons': specifier: 1.2.3 version: 1.2.3 @@ -109,8 +109,8 @@ catalogs: specifier: 1.14.6 version: 1.14.6 '@playwright/test': - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.61.0 + version: 1.61.0 '@remixicon/react': specifier: 4.9.0 version: 4.9.0 @@ -118,35 +118,35 @@ catalogs: specifier: 4.2.0 version: 4.2.0 '@sentry/react': - specifier: 10.57.0 - version: 10.57.0 + specifier: 10.59.0 + version: 10.59.0 '@storybook/addon-a11y': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/addon-docs': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/addon-links': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/addon-onboarding': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/addon-themes': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/addon-vitest': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/nextjs-vite': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/react': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@storybook/react-vite': - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 '@streamdown/math': specifier: 1.0.2 version: 1.0.2 @@ -168,6 +168,9 @@ catalogs: '@tanstack/eslint-plugin-query': specifier: 5.101.0 version: 5.101.0 + '@tanstack/form-core': + specifier: 1.33.0 + version: 1.33.0 '@tanstack/query-core': specifier: 5.101.0 version: 5.101.0 @@ -181,8 +184,8 @@ catalogs: specifier: 5.101.0 version: 5.101.0 '@tanstack/react-virtual': - specifier: 3.14.2 - version: 3.14.2 + specifier: 3.14.3 + version: 3.14.3 '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -196,14 +199,14 @@ catalogs: specifier: 14.6.1 version: 14.6.1 '@tsslint/cli': - specifier: 3.1.3 - version: 3.1.3 + specifier: 3.1.4 + version: 3.1.4 '@tsslint/compat-eslint': - specifier: 3.1.3 - version: 3.1.3 + specifier: 3.1.4 + version: 3.1.4 '@tsslint/config': - specifier: 3.1.3 - version: 3.1.3 + specifier: 3.1.4 + version: 3.1.4 '@types/js-cookie': specifier: 3.0.6 version: 3.0.6 @@ -217,8 +220,8 @@ catalogs: specifier: 0.6.4 version: 0.6.4 '@types/node': - specifier: 25.9.3 - version: 25.9.3 + specifier: 25.9.4 + version: 25.9.4 '@types/qs': specifier: 6.15.1 version: 6.15.1 @@ -232,14 +235,14 @@ catalogs: specifier: 1.15.9 version: 1.15.9 '@typescript-eslint/eslint-plugin': - specifier: 8.61.0 - version: 8.61.0 + specifier: 8.61.1 + version: 8.61.1 '@typescript-eslint/parser': - specifier: 8.61.0 - version: 8.61.0 + specifier: 8.61.1 + version: 8.61.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260613.1 - version: 7.0.0-dev.20260613.1 + specifier: 7.0.0-dev.20260620.1 + version: 7.0.0-dev.20260620.1 '@vitejs/plugin-react': specifier: 6.0.2 version: 6.0.2 @@ -247,11 +250,14 @@ catalogs: specifier: 0.5.27 version: 0.5.27 '@vitest/browser': - specifier: 4.1.8 - version: 4.1.8 + specifier: 4.1.9 + version: 4.1.9 + '@vitest/browser-playwright': + specifier: 4.1.9 + version: 4.1.9 '@vitest/coverage-v8': - specifier: 4.1.8 - version: 4.1.8 + specifier: 4.1.9 + version: 4.1.9 abcjs: specifier: 6.6.3 version: 6.6.3 @@ -280,8 +286,8 @@ catalogs: specifier: 1.1.1 version: 1.1.1 code-inspector-plugin: - specifier: 1.6.0 - version: 1.6.0 + specifier: 1.6.1 + version: 1.6.1 concurrently: specifier: ^10.0.3 version: 10.0.3 @@ -289,8 +295,8 @@ catalogs: specifier: 4.0.2 version: 4.0.2 cron-parser: - specifier: 5.5.0 - version: 5.5.0 + specifier: 5.6.0 + version: 5.6.0 dayjs: specifier: 1.11.21 version: 1.11.21 @@ -298,8 +304,8 @@ catalogs: specifier: 10.6.0 version: 10.6.0 dompurify: - specifier: 3.4.10 - version: 3.4.10 + specifier: 3.4.11 + version: 3.4.11 echarts: specifier: 6.1.0 version: 6.1.0 @@ -349,11 +355,11 @@ catalogs: specifier: 0.5.3 version: 0.5.3 eslint-plugin-sonarjs: - specifier: 4.0.3 - version: 4.0.3 + specifier: 4.1.0 + version: 4.1.0 eslint-plugin-storybook: - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 eventsource-parser: specifier: 3.1.0 version: 3.1.0 @@ -361,20 +367,20 @@ catalogs: specifier: 3.1.3 version: 3.1.3 foxact: - specifier: 0.3.5 - version: 0.3.5 + specifier: 0.3.7 + version: 0.3.7 fuse.js: specifier: 7.4.2 version: 7.4.2 happy-dom: - specifier: 20.10.3 - version: 20.10.3 + specifier: 20.10.6 + version: 20.10.6 hast-util-to-jsx-runtime: specifier: 2.3.6 version: 2.3.6 hono: - specifier: 4.12.25 - version: 4.12.25 + specifier: 4.12.26 + version: 4.12.26 html-entities: specifier: 2.6.0 version: 2.6.0 @@ -418,8 +424,8 @@ catalogs: specifier: 0.17.0 version: 0.17.0 knip: - specifier: 6.16.1 - version: 6.16.1 + specifier: 6.17.1 + version: 6.17.1 ky: specifier: 2.0.2 version: 2.0.2 @@ -433,8 +439,8 @@ catalogs: specifier: 1.0.4 version: 1.0.4 loro-crdt: - specifier: 1.13.2 - version: 1.13.2 + specifier: 1.13.5 + version: 1.13.5 mermaid: specifier: 11.15.0 version: 11.15.0 @@ -472,8 +478,8 @@ catalogs: specifier: 3.28.1 version: 3.28.1 playwright: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.61.0 + version: 1.61.0 postcss: specifier: 8.5.15 version: 8.5.15 @@ -529,8 +535,8 @@ catalogs: specifier: 0.0.1 version: 0.0.1 sharp: - specifier: 0.35.1 - version: 0.35.1 + specifier: 0.35.2 + version: 0.35.2 shiki: specifier: 4.2.0 version: 4.2.0 @@ -544,8 +550,8 @@ catalogs: specifier: 1.0.8 version: 1.0.8 storybook: - specifier: 10.4.4 - version: 10.4.4 + specifier: 10.4.6 + version: 10.4.6 streamdown: specifier: 2.5.0 version: 2.5.0 @@ -559,8 +565,8 @@ catalogs: specifier: 4.3.1 version: 4.3.1 tldts: - specifier: 7.4.2 - version: 7.4.2 + specifier: 7.4.3 + version: 7.4.3 tsx: specifier: 4.22.4 version: 4.22.4 @@ -571,8 +577,8 @@ catalogs: specifier: 3.19.3 version: 3.19.3 undici: - specifier: 7.27.2 - version: 7.27.2 + specifier: 7.28.0 + version: 7.28.0 unist-util-visit: specifier: 5.1.0 version: 5.1.0 @@ -580,17 +586,17 @@ catalogs: specifier: 2.0.0 version: 2.0.0 uuid: - specifier: 14.0.0 - version: 14.0.0 + specifier: 14.0.1 + version: 14.0.1 vinext: - specifier: 0.1.2 - version: 0.1.2 + specifier: 0.1.6 + version: 0.1.6 vite-plugin-inspect: specifier: 12.0.0-beta.3 version: 12.0.0-beta.3 vite-plus: - specifier: 0.1.24 - version: 0.1.24 + specifier: 0.2.1 + version: 0.2.1 vitest-browser-react: specifier: 2.2.0 version: 2.2.0 @@ -608,29 +614,29 @@ catalogs: version: 5.0.14 overrides: + '@babel/core@<=7.29.0': ^7.29.1 '@lexical/code': npm:lexical-code-no-prism@0.41.0 canvas: ^3.2.3 esbuild@<0.27.2: 0.27.2 esbuild@>=0.17.0 <0.28.1: ^0.28.1 esbuild@>=0.27.3 <0.28.1: ^0.28.1 is-core-module: npm:@nolyfill/is-core-module@^1.0.39 + js-yaml@<=4.1.1: ^4.1.2 picomatch@>=4.0.0 <4.0.4: 4.0.4 - postcss@<8.5.10: ^8.5.10 postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4 - rollup@>=4.0.0 <4.59.0: 4.61.1 + postcss@<8.5.10: ^8.5.10 + rollup@>=4.0.0 <4.59.0: 4.62.2 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 solid-js: 1.9.13 string-width: ~8.2.1 - vite: npm:@voidzero-dev/vite-plus-core@0.1.24 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 + tar@<=7.5.15: ^7.5.16 + vite: npm:@voidzero-dev/vite-plus-core@0.2.1 + vitest: 4.1.9 ws@>=8.0.0 <8.20.1: ^8.21.0 yaml@>=2.0.0 <2.8.3: 2.9.0 yauzl@<3.2.1: 3.2.1 - '@babel/core@<=7.29.0': ^7.29.1 - js-yaml@<=4.1.1: ^4.1.2 - tar@<=7.5.15: ^7.5.16 importers: @@ -638,7 +644,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.0.0(b376e15be293d4e014f0f69f32d1fb4a) + version: 9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.9) concurrently: specifier: 'catalog:' version: 10.0.3 @@ -658,11 +664,11 @@ importers: specifier: runtime:^22.22.1 version: runtime:22.22.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) cli: dependencies: @@ -707,7 +713,7 @@ importers: version: 1.0.8 undici: specifier: 'catalog:' - version: 7.27.2 + version: 7.28.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -717,7 +723,7 @@ importers: version: link:../packages/tsconfig '@hono/node-server': specifier: 'catalog:' - version: 2.0.4(hono@4.12.25) + version: 2.0.5(hono@4.12.26) '@types/js-yaml': specifier: 'catalog:' version: 4.0.9 @@ -726,31 +732,31 @@ importers: version: 1.0.4 '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24) + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) hono: specifier: 'catalog:' - version: 4.12.25 + version: 4.12.26 typescript: specifier: 'catalog:' version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.24 - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) e2e: devDependencies: @@ -762,13 +768,13 @@ importers: version: link:../packages/tsconfig '@playwright/test': specifier: 'catalog:' - version: 1.60.0 + version: 1.61.0 '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 tsx: specifier: 'catalog:' version: 4.22.4 @@ -776,11 +782,11 @@ importers: specifier: 'catalog:' version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) packages/contracts: dependencies: @@ -802,10 +808,10 @@ importers: version: 4.0.9 '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) @@ -817,13 +823,13 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) packages/dev-proxy: dependencies: '@hono/node-server': specifier: 'catalog:' - version: 2.0.4(hono@4.12.25) + version: 2.0.5(hono@4.12.26) c12: specifier: 'catalog:' version: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.2.0)(jiti@2.7.0)(magicast@0.5.2) @@ -832,26 +838,26 @@ importers: version: 5.0.0 hono: specifier: 'catalog:' - version: 4.12.25 + version: 4.12.26 devDependencies: '@dify/tsconfig': specifier: workspace:* version: link:../tsconfig '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.24 - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) packages/dify-ui: dependencies: @@ -867,7 +873,7 @@ importers: version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.2.1(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 5.2.1(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@dify/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -879,31 +885,31 @@ importers: version: 1.2.10 '@storybook/addon-a11y': specifier: 'catalog:' - version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-docs': specifier: 'catalog:' - version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.4.4(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.4(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(@vitest/browser-playwright@4.1.9)(@vitest/browser@4.1.9)(@vitest/runner@4.1.9)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(vitest@4.1.9) '@storybook/react-vite': specifier: 'catalog:' - version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 4.3.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@tanstack/react-hotkeys': specifier: 'catalog:' version: 0.10.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': specifier: 'catalog:' - version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -912,22 +918,25 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/browser': specifier: 'catalog:' - version: 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24) + version: 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(playwright@1.61.0)(vitest@4.1.9) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24) + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) class-variance-authority: specifier: 'catalog:' version: 0.7.1 playwright: specifier: 'catalog:' - version: 1.60.0 + version: 1.61.0 react: specifier: 'catalog:' version: 19.2.7 @@ -936,7 +945,7 @@ importers: version: 19.2.7(react@19.2.7) storybook: specifier: 'catalog:' - version: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.1 @@ -944,17 +953,17 @@ importers: specifier: 'catalog:' version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.24 - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) vitest-browser-react: specifier: 'catalog:' - version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) packages/iconify-collections: devDependencies: @@ -965,11 +974,38 @@ importers: specifier: 'catalog:' version: 4.22.4 + packages/jotai-tanstack-form: + devDependencies: + '@dify/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@tanstack/form-core': + specifier: 'catalog:' + version: 1.33.0 + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260620.1 + jotai: + specifier: 'catalog:' + version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite-plus: + specifier: 'catalog:' + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) + packages/migrate-no-unchecked-indexed-access: dependencies: '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 typescript: specifier: 'catalog:' version: 6.0.3 @@ -979,13 +1015,13 @@ importers: version: link:../tsconfig '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) packages/tsconfig: {} @@ -999,19 +1035,19 @@ importers: version: 10.0.1(eslint@10.5.0(jiti@2.7.0)) '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + version: 8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24) + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) @@ -1019,14 +1055,14 @@ importers: specifier: 'catalog:' version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.24 - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) web: dependencies: @@ -1092,7 +1128,7 @@ importers: version: 4.9.0(react@19.2.7) '@sentry/react': specifier: 'catalog:' - version: 10.57.0(react@19.2.7) + version: 10.59.0(react@19.2.7) '@streamdown/math': specifier: 'catalog:' version: 1.0.2(react@19.2.7) @@ -1105,6 +1141,9 @@ importers: '@tailwindcss/typography': specifier: 'catalog:' version: 0.5.20(tailwindcss@4.3.1) + '@tanstack/form-core': + specifier: 'catalog:' + version: 1.33.0 '@tanstack/query-core': specifier: 'catalog:' version: 5.101.0 @@ -1119,7 +1158,7 @@ importers: version: 5.101.0(react@19.2.7) '@tanstack/react-virtual': specifier: 'catalog:' - version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) abcjs: specifier: 'catalog:' version: 6.6.3 @@ -1137,7 +1176,7 @@ importers: version: 4.0.2 cron-parser: specifier: 'catalog:' - version: 5.5.0 + version: 5.6.0 dayjs: specifier: 'catalog:' version: 1.11.21 @@ -1146,7 +1185,7 @@ importers: version: 10.6.0 dompurify: specifier: 'catalog:' - version: 3.4.10 + version: 3.4.11 echarts: specifier: 'catalog:' version: 6.1.0 @@ -1176,7 +1215,7 @@ importers: version: 3.1.3 foxact: specifier: 'catalog:' - version: 0.3.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.3.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) fuse.js: specifier: 'catalog:' version: 7.4.2 @@ -1204,6 +1243,9 @@ importers: jotai-scope: specifier: 'catalog:' version: 0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + jotai-tanstack-form: + specifier: workspace:* + version: link:../packages/jotai-tanstack-form jotai-tanstack-query: specifier: 'catalog:' version: 0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) @@ -1233,7 +1275,7 @@ importers: version: 0.45.0 loro-crdt: specifier: 'catalog:' - version: 1.13.2 + version: 1.13.5 mermaid: specifier: 'catalog:' version: 11.15.0 @@ -1251,13 +1293,13 @@ importers: version: 1.0.0 next: specifier: 'catalog:' - version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' - version: 2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) pinyin-pro: specifier: 'catalog:' version: 3.28.1 @@ -1311,7 +1353,7 @@ importers: version: 0.0.1 sharp: specifier: 'catalog:' - version: 0.35.1 + version: 0.35.2 shiki: specifier: 'catalog:' version: 4.2.0 @@ -1332,7 +1374,7 @@ importers: version: 2.3.1 tldts: specifier: 'catalog:' - version: 7.4.2 + version: 7.4.3 unist-util-visit: specifier: 'catalog:' version: 5.1.0 @@ -1341,7 +1383,7 @@ importers: version: 2.0.0(react@19.2.7)(scheduler@0.27.0) uuid: specifier: 'catalog:' - version: 14.0.0 + version: 14.0.1 zod: specifier: 'catalog:' version: 4.4.3 @@ -1354,10 +1396,10 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@types/node@25.9.3)(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)(vitest@4.1.9) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.2.1(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 5.2.1(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@dify/contracts': specifier: workspace:* version: link:../packages/contracts @@ -1405,28 +1447,28 @@ importers: version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.4.4(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-onboarding': specifier: 'catalog:' - version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.4.4(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) + version: 10.4.6(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react': specifier: 'catalog:' - version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@tailwindcss/postcss': specifier: 'catalog:' version: 4.3.1 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 4.3.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@tanstack/eslint-plugin-query': specifier: 'catalog:' version: 5.101.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -1444,13 +1486,13 @@ importers: version: 14.6.1(@testing-library/dom@10.4.1) '@tsslint/cli': specifier: 'catalog:' - version: 3.1.3(@tsslint/compat-eslint@3.1.3(typescript@6.0.3))(typescript@6.0.3) + version: 3.1.4(@tsslint/compat-eslint@3.1.4(typescript@6.0.3))(typescript@6.0.3) '@tsslint/compat-eslint': specifier: 'catalog:' - version: 3.1.3(typescript@6.0.3) + version: 3.1.4(typescript@6.0.3) '@tsslint/config': specifier: 'catalog:' - version: 3.1.3(@tsslint/compat-eslint@3.1.3(typescript@6.0.3)) + version: 3.1.4(@tsslint/compat-eslint@3.1.4(typescript@6.0.3)) '@types/js-cookie': specifier: 'catalog:' version: 3.0.6 @@ -1462,7 +1504,7 @@ importers: version: 0.6.4 '@types/node': specifier: 'catalog:' - version: 25.9.3 + version: 25.9.4 '@types/qs': specifier: 'catalog:' version: 6.15.1 @@ -1477,25 +1519,25 @@ importers: version: 1.15.9 '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + version: 8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260613.1 + version: 7.0.0-dev.20260620.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24) + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) agentation: specifier: 'catalog:' version: 3.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) code-inspector-plugin: specifier: 'catalog:' - version: 1.6.0(supports-color@10.2.2) + version: 1.6.1(supports-color@10.2.2) eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) @@ -1504,7 +1546,7 @@ importers: version: 0.11.0(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.6.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tailwindcss@4.3.1)(typescript@6.0.3) + version: 4.6.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tailwindcss@4.3.1)(typescript@6.0.3) eslint-plugin-hyoban: specifier: 'catalog:' version: 0.14.1(eslint@10.5.0(jiti@2.7.0)) @@ -1522,16 +1564,16 @@ importers: version: 0.5.3(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-sonarjs: specifier: 'catalog:' - version: 4.0.3(eslint@10.5.0(jiti@2.7.0)) + version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.4.4(eslint@10.5.0(jiti@2.7.0))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.6(eslint@10.5.0(jiti@2.7.0))(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) happy-dom: specifier: 'catalog:' - version: 20.10.3 + version: 20.10.6 knip: specifier: 'catalog:' - version: 6.16.1 + version: 6.17.1 postcss: specifier: 'catalog:' version: 8.5.15 @@ -1540,7 +1582,7 @@ importers: version: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) storybook: specifier: 'catalog:' - version: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.1 @@ -1555,22 +1597,22 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3) + version: 0.1.6(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 - version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@0.2.1 + version: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plugin-inspect: specifier: 'catalog:' - version: 12.0.0-beta.3(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) + version: 12.0.0-beta.3(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + version: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.24 - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + specifier: 4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.24) + version: 1.1.4(vitest@4.1.9) packages: @@ -1738,10 +1780,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -1750,10 +1788,6 @@ packages: resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -1768,10 +1802,6 @@ packages: peerDependencies: '@babel/core': ^7.29.1 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -1792,10 +1822,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -1805,26 +1831,14 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1883,23 +1897,23 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} - '@code-inspector/core@1.6.0': - resolution: {integrity: sha512-RMJA9RQVpU12L5Df32lhOC7kh0CwBbydaqr/dQmTEX9rjcr2cyEAalWSaglcMber6+iOHDZSiyaFIJCGKOSV3w==} + '@code-inspector/core@1.6.1': + resolution: {integrity: sha512-jlRjItyW7AlcEAi3oqxq11NsGdtfvG2ZOCs8mJQAlRhIKGEIWSECgN5oNzuOICOF+eRVHxUfBL4gd0FiFfaDuQ==} - '@code-inspector/esbuild@1.6.0': - resolution: {integrity: sha512-IDSOrQUaKDecklfd5wNqgU3j4TSlcqLspWw5+xGS8n5cFACy2kA4aM+vE4xWK3OXiooLB9sOGbVcOaZzda2hhA==} + '@code-inspector/esbuild@1.6.1': + resolution: {integrity: sha512-inZSNq+ZsbgZX48u/O9kwVNnr6RiMpBTlOhmfVx/bxCIs+GkGalVuY6AZ5+epg5QiNbL9RdHBkckEZpPAFDHgg==} - '@code-inspector/mako@1.6.0': - resolution: {integrity: sha512-99fPSBfbEiYMpZWHc2NSShzxDecNgn8ykziqeYjSqnNZTdCjhlEmMR/KymOMoI/Ott1wLM3H5fqa7Lpehx9NxQ==} + '@code-inspector/mako@1.6.1': + resolution: {integrity: sha512-mk8qfMOhEaGwvxruIudv2edZ5xaREaoMDmaiou4qEkfxfpiQ+8oKKGCQhNw8ML2wgVLUEeizCQO60IWpBJnWxg==} - '@code-inspector/turbopack@1.6.0': - resolution: {integrity: sha512-hhJGozLBa53NNi4Apn27Di0DAdfGzjFB5/iVsklQHKOxnGSJ7VviPO01Bi2W6sfOc6tY27wvsys1I1kc42KOLw==} + '@code-inspector/turbopack@1.6.1': + resolution: {integrity: sha512-4jY89hyU4p7DtTqEyr4qYnnaQSGvtZvrWr8PEaYYR0EDK+qHDl6ndupZBYU2zzKMv87gnWqZ271rPA+0YXfEBg==} - '@code-inspector/vite@1.6.0': - resolution: {integrity: sha512-P406JrDxZ8iGr26X3YzRwuR/jeYtrAhhjJu9BH0FpXRVcMolyp3a4kvMYzZ2IfulAxNMvcEAZsfr4DHLBFNktg==} + '@code-inspector/vite@1.6.1': + resolution: {integrity: sha512-ZlfS23t9naunoA8xBAlaEgEAFWq6YV1frKgE42fXZUNYOmcZlpaV/8EdqBJt/JZu6MJkceEVmgGRSJc5kYXIag==} - '@code-inspector/webpack@1.6.0': - resolution: {integrity: sha512-TVEYQ/hgWpyNTibUQh/nf/01ipDdP9/VGJJhL2eVpyBKlP4k8ipKusXaCEbC4hvUQsak6T11rwPQIXIf+WONRQ==} + '@code-inspector/webpack@1.6.1': + resolution: {integrity: sha512-pgrqzBja1FBk0WWXodmbfOnpLX4rbofBPk4INHc9UWnDlv1Ypfhn9pX7N5zJ0pBRHjrBF6ow4xfm0GGb7eUG2w==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -2362,8 +2376,8 @@ packages: '@hey-api/types@0.1.4': resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} - '@hono/node-server@2.0.4': - resolution: {integrity: sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==} + '@hono/node-server@2.0.5': + resolution: {integrity: sha512-yQFvDmyDo3y6rEOJZDUYPJ49DIKTPpIk4kGvm40xx4Ejne0Pu9a1+exxPN+C1UppWK/WGZX9F++/Xs231tE86g==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -2412,8 +2426,8 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-darwin-arm64@0.35.1': - resolution: {integrity: sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==} + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] @@ -2424,14 +2438,14 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-darwin-x64@0.35.1': - resolution: {integrity: sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==} + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.1': - resolution: {integrity: sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==} + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} engines: {node: '>=20.9.0'} os: [freebsd] @@ -2440,8 +2454,8 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.3.0': - resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==} + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} cpu: [arm64] os: [darwin] @@ -2450,8 +2464,8 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.0': - resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==} + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} cpu: [x64] os: [darwin] @@ -2461,8 +2475,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm64@1.3.0': - resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==} + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] libc: [glibc] @@ -2473,8 +2487,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.0': - resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==} + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] libc: [glibc] @@ -2485,8 +2499,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.0': - resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==} + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] libc: [glibc] @@ -2497,8 +2511,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.0': - resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==} + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] libc: [glibc] @@ -2509,8 +2523,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.0': - resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==} + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] libc: [glibc] @@ -2521,8 +2535,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.0': - resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==} + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] libc: [glibc] @@ -2533,8 +2547,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-arm64@1.3.0': - resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] libc: [musl] @@ -2545,8 +2559,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.0': - resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] libc: [musl] @@ -2558,8 +2572,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm64@0.35.1': - resolution: {integrity: sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==} + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] @@ -2572,8 +2586,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.1': - resolution: {integrity: sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==} + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] @@ -2586,8 +2600,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.1': - resolution: {integrity: sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==} + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] @@ -2600,8 +2614,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.1': - resolution: {integrity: sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==} + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] @@ -2614,8 +2628,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.1': - resolution: {integrity: sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==} + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] @@ -2628,8 +2642,8 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.1': - resolution: {integrity: sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==} + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] @@ -2642,8 +2656,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-arm64@0.35.1': - resolution: {integrity: sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==} + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] @@ -2656,8 +2670,8 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.1': - resolution: {integrity: sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==} + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] @@ -2668,12 +2682,12 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-wasm32@0.35.1': - resolution: {integrity: sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==} + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.1': - resolution: {integrity: sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==} + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} engines: {node: '>=20.9.0'} cpu: [wasm32] @@ -2683,8 +2697,8 @@ packages: cpu: [arm64] os: [win32] - '@img/sharp-win32-arm64@0.35.1': - resolution: {integrity: sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==} + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] @@ -2695,8 +2709,8 @@ packages: cpu: [ia32] os: [win32] - '@img/sharp-win32-ia32@0.35.1': - resolution: {integrity: sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==} + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] @@ -2707,8 +2721,8 @@ packages: cpu: [x64] os: [win32] - '@img/sharp-win32-x64@0.35.1': - resolution: {integrity: sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==} + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -2847,7 +2861,7 @@ packages: '@mdx-js/rollup@3.1.1': resolution: {integrity: sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} @@ -3096,8 +3110,8 @@ packages: cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm-eabi@0.133.0': - resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} + '@oxc-parser/binding-android-arm-eabi@0.135.0': + resolution: {integrity: sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] @@ -3114,8 +3128,8 @@ packages: cpu: [arm64] os: [android] - '@oxc-parser/binding-android-arm64@0.133.0': - resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==} + '@oxc-parser/binding-android-arm64@0.135.0': + resolution: {integrity: sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -3132,8 +3146,8 @@ packages: cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-arm64@0.133.0': - resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==} + '@oxc-parser/binding-darwin-arm64@0.135.0': + resolution: {integrity: sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -3150,8 +3164,8 @@ packages: cpu: [x64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.133.0': - resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==} + '@oxc-parser/binding-darwin-x64@0.135.0': + resolution: {integrity: sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -3168,8 +3182,8 @@ packages: cpu: [x64] os: [freebsd] - '@oxc-parser/binding-freebsd-x64@0.133.0': - resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==} + '@oxc-parser/binding-freebsd-x64@0.135.0': + resolution: {integrity: sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -3186,8 +3200,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': - resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.135.0': + resolution: {integrity: sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3204,8 +3218,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': - resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==} + '@oxc-parser/binding-linux-arm-musleabihf@0.135.0': + resolution: {integrity: sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3224,8 +3238,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': - resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==} + '@oxc-parser/binding-linux-arm64-gnu@0.135.0': + resolution: {integrity: sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3245,8 +3259,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-arm64-musl@0.133.0': - resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==} + '@oxc-parser/binding-linux-arm64-musl@0.135.0': + resolution: {integrity: sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3266,8 +3280,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': - resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.135.0': + resolution: {integrity: sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -3287,8 +3301,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': - resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==} + '@oxc-parser/binding-linux-riscv64-gnu@0.135.0': + resolution: {integrity: sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] @@ -3308,8 +3322,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': - resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==} + '@oxc-parser/binding-linux-riscv64-musl@0.135.0': + resolution: {integrity: sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] @@ -3329,8 +3343,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': - resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==} + '@oxc-parser/binding-linux-s390x-gnu@0.135.0': + resolution: {integrity: sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -3350,8 +3364,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.133.0': - resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==} + '@oxc-parser/binding-linux-x64-gnu@0.135.0': + resolution: {integrity: sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3371,8 +3385,8 @@ packages: os: [linux] libc: [musl] - '@oxc-parser/binding-linux-x64-musl@0.133.0': - resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==} + '@oxc-parser/binding-linux-x64-musl@0.135.0': + resolution: {integrity: sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3390,8 +3404,8 @@ packages: cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-openharmony-arm64@0.133.0': - resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==} + '@oxc-parser/binding-openharmony-arm64@0.135.0': + resolution: {integrity: sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3406,8 +3420,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-wasm32-wasi@0.133.0': - resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==} + '@oxc-parser/binding-wasm32-wasi@0.135.0': + resolution: {integrity: sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] @@ -3423,8 +3437,8 @@ packages: cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': - resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==} + '@oxc-parser/binding-win32-arm64-msvc@0.135.0': + resolution: {integrity: sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3441,8 +3455,8 @@ packages: cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': - resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==} + '@oxc-parser/binding-win32-ia32-msvc@0.135.0': + resolution: {integrity: sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] @@ -3459,14 +3473,14 @@ packages: cpu: [x64] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.133.0': - resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==} + '@oxc-parser/binding-win32-x64-msvc@0.135.0': + resolution: {integrity: sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/runtime@0.133.0': - resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} + '@oxc-project/runtime@0.136.0': + resolution: {integrity: sha512-u0EutjK5y6NHJkl5jNJCs8zbup1z6A/UEWgajrYzqcEU3UX05HjqybhMQOLhSM0eKGISyM6WfSMMuklYSmH2wA==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.127.0': @@ -3475,8 +3489,11 @@ packages: '@oxc-project/types@0.132.0': resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.135.0': + resolution: {integrity: sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==} + + '@oxc-project/types@0.136.0': + resolution: {integrity: sha512-39Al/B3v9esnHCX7S8l9Se2+s2tb9b2jcMd+bZ2L659VG73kNyGPpPrL5Zi/p0ty7p4pTTU2/Dd+g27hv94XCg==} '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} @@ -3581,124 +3598,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.52.0': - resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} + '@oxfmt/binding-android-arm-eabi@0.55.0': + resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.52.0': - resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} + '@oxfmt/binding-android-arm64@0.55.0': + resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.52.0': - resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} + '@oxfmt/binding-darwin-arm64@0.55.0': + resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.52.0': - resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} + '@oxfmt/binding-darwin-x64@0.55.0': + resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.52.0': - resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} + '@oxfmt/binding-freebsd-x64@0.55.0': + resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': - resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': + resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': - resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': + resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.52.0': - resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} + '@oxfmt/binding-linux-arm64-gnu@0.55.0': + resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.52.0': - resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} + '@oxfmt/binding-linux-arm64-musl@0.55.0': + resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': - resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': + resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': - resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': + resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.52.0': - resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} + '@oxfmt/binding-linux-riscv64-musl@0.55.0': + resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.52.0': - resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} + '@oxfmt/binding-linux-s390x-gnu@0.55.0': + resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.52.0': - resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} + '@oxfmt/binding-linux-x64-gnu@0.55.0': + resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.52.0': - resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} + '@oxfmt/binding-linux-x64-musl@0.55.0': + resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.52.0': - resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} + '@oxfmt/binding-openharmony-arm64@0.55.0': + resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.52.0': - resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} + '@oxfmt/binding-win32-arm64-msvc@0.55.0': + resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.52.0': - resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} + '@oxfmt/binding-win32-ia32-msvc@0.55.0': + resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.52.0': - resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} + '@oxfmt/binding-win32-x64-msvc@0.55.0': + resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3733,139 +3750,140 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.67.0': - resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} + '@oxlint/binding-android-arm-eabi@1.70.0': + resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.67.0': - resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} + '@oxlint/binding-android-arm64@1.70.0': + resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.67.0': - resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} + '@oxlint/binding-darwin-arm64@1.70.0': + resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.67.0': - resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} + '@oxlint/binding-darwin-x64@1.70.0': + resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.67.0': - resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} + '@oxlint/binding-freebsd-x64@1.70.0': + resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': - resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} + '@oxlint/binding-linux-arm-gnueabihf@1.70.0': + resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.67.0': - resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} + '@oxlint/binding-linux-arm-musleabihf@1.70.0': + resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.67.0': - resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} + '@oxlint/binding-linux-arm64-gnu@1.70.0': + resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.67.0': - resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} + '@oxlint/binding-linux-arm64-musl@1.70.0': + resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.67.0': - resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} + '@oxlint/binding-linux-ppc64-gnu@1.70.0': + resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.67.0': - resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} + '@oxlint/binding-linux-riscv64-gnu@1.70.0': + resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.67.0': - resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} + '@oxlint/binding-linux-riscv64-musl@1.70.0': + resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.67.0': - resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} + '@oxlint/binding-linux-s390x-gnu@1.70.0': + resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.67.0': - resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} + '@oxlint/binding-linux-x64-gnu@1.70.0': + resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.67.0': - resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} + '@oxlint/binding-linux-x64-musl@1.70.0': + resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.67.0': - resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} + '@oxlint/binding-openharmony-arm64@1.70.0': + resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.67.0': - resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} + '@oxlint/binding-win32-arm64-msvc@1.70.0': + resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.67.0': - resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} + '@oxlint/binding-win32-ia32-msvc@1.70.0': + resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.67.0': - resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} + '@oxlint/binding-win32-x64-msvc@1.70.0': + resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/plugins@1.61.0': - resolution: {integrity: sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==} + '@oxlint/plugins@1.68.0': + resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.60.0': - resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} engines: {node: '>=18'} + hasBin: true '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4122,7 +4140,7 @@ packages: resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -4131,41 +4149,41 @@ packages: resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true - '@sentry-internal/browser-utils@10.57.0': - resolution: {integrity: sha512-tXObp954rMTSYKlbftjVXHtNl4t/6ssks3jkqyzmKb+PDPWzabGQO7sWwqVuTjT8Kx/8A3FmriS1bGmqxiJy3A==} + '@sentry/browser-utils@10.59.0': + resolution: {integrity: sha512-DpJIrNi0Hsj/YONTZ8km1wv7ue2NzrTmFKJ3lRW8Q2nS/mrMeP3LCvjreLtKheTouB4go58NxM68AEFbXk4rPg==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.57.0': - resolution: {integrity: sha512-ZcF4QhkqGX3iiQSXB2N0N3Awp+j5iqnDRu6PA/qyLFrWqH5ZiiAAgu59OLD9E6XAdg6iFtLYw19MAMZVK8qNOQ==} + '@sentry/browser@10.59.0': + resolution: {integrity: sha512-d0o0oc78KNWCZ6yq9gREf9BPXWza/Wj9W+nCm0CvPD5k2IbouD/eJsm2Mb+d53YfEm12L+SePfgOx01KbbVx4A==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.57.0': - resolution: {integrity: sha512-zsfa4JcfV0AEc9YhNxNabd5lSZL2Av84saAyexGAqcHs+67m9Gd0cGStOzMb/nCl7UAtmdP0aI+G7a3rcxxN/A==} + '@sentry/core@10.59.0': + resolution: {integrity: sha512-QeG7XZL5j6CkToYCE7OwCerb/r742Tjj9p1BBohBKcypYTPRuqfD+A3FeUj7pk5CGO6Vj1/gOAmdbuuNbR51dQ==} engines: {node: '>=18'} - '@sentry-internal/replay@10.57.0': - resolution: {integrity: sha512-Wmnx/6ABynVH1iwuoNUqJNyjIUqsqoGML7qsyivBRKb5Wo2YQtPOQlQYfxfZSvWzGpcoSVdInkRjDssUQxQEQg==} + '@sentry/feedback@10.59.0': + resolution: {integrity: sha512-Sa/06LlG/mYR4z8/JlxOwvkcOHJnaMW6JyTMKNsgEoR1SHZULIpirjUuHIp4P+7R1mqX/KGTj8I7SQ3adA0TxQ==} engines: {node: '>=18'} - '@sentry/browser@10.57.0': - resolution: {integrity: sha512-s36AQy/CKXTfyY9Z+qUhzNomntZXgfs0rbaK7q9ffnFkqcPwzE8qQtVs58y3Suut56u+AhwSztgQtERcuZ5VIA==} - engines: {node: '>=18'} - - '@sentry/core@10.57.0': - resolution: {integrity: sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg==} - engines: {node: '>=18'} - - '@sentry/react@10.57.0': - resolution: {integrity: sha512-6QThwQ4XWQ2rwKZEVQ9P9WKl7JlowC7S5LpAvmMdrwlfJBpLDFOsM7tycnIvbXTXf0ZOOuLFPa4L4YYbdyNGmA==} + '@sentry/react@10.59.0': + resolution: {integrity: sha512-B3MmXooGLnTu0gyL8hxElCu1+WVEXjIGoDPvGqn5qMHZNSmB2oPTAt1/UutuxAc6EWu2UKIHnyPowNeY2MPH3g==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x + '@sentry/replay-canvas@10.59.0': + resolution: {integrity: sha512-bJkqvxQiat27P7+hUYC/m545Ct/uVxZCjh+PeCFdRcuHnZZ92e6Z7XPLf0LqAR6tR1U7wDUa4V5ugrMIEz+vpQ==} + engines: {node: '>=18'} + + '@sentry/replay@10.59.0': + resolution: {integrity: sha512-fpHL25JErsSfTyusuyZ3Q5JmRZeWYWynJO3PNiQH6A/58EqaCp8U5y8LCJ+CSPaeuBMka3S3Qn6U4eXcvkDMRA==} + engines: {node: '>=18'} + '@shikijs/core@4.2.0': resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==} engines: {node: '>=20'} @@ -4200,6 +4218,7 @@ packages: '@shuding/opentype.js@1.4.0-beta.0': resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} engines: {node: '>= 8.0.0'} + hasBin: true '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} @@ -4214,50 +4233,50 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-a11y@10.4.4': - resolution: {integrity: sha512-/eUCx/6Ozq5grauwm/NqKtlW0oJ26b6GNesXrMuFID8WLg/qLEKf79Awfz9XrmyWxe7loD40K952r7AA5Oc23A==} + '@storybook/addon-a11y@10.4.6': + resolution: {integrity: sha512-XCJy+f0DFOiCgUU9knRDlLDxVFI+AAQ3/wE/NF85zB9iDPPS2DwkSN+mas3zDgHt66zhN8Cq3/UiyCDUweV9Zw==} peerDependencies: - storybook: ^10.4.4 + storybook: ^10.4.6 - '@storybook/addon-docs@10.4.4': - resolution: {integrity: sha512-yPshCvtmQTq52T2sXuXgjy7B/QbhA/WIZxLYggptNjBL8BJMvbOfp9bAfCKh7+KpRWGqDZ6Y6tWL1Q48Wj3vtw==} + '@storybook/addon-docs@10.4.6': + resolution: {integrity: sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-links@10.4.4': - resolution: {integrity: sha512-sWydPWLgduT24p/NJ/hXHcHsPlAyzQP+cOtCGliSI989K9yBP/TOL3A8sz7LIDfukI9DVAsylPhJ1jDSiAEI1w==} + '@storybook/addon-links@10.4.6': + resolution: {integrity: sha512-VGfERTsGRFmfvNP3SKprFWkC6Od5kXzSutT5PSZjQ/O9NnCdHhd/RILxFDN2TzZn9ywDc7t5b4AldKmSYCv3EQ==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 peerDependenciesMeta: '@types/react': optional: true react: optional: true - '@storybook/addon-onboarding@10.4.4': - resolution: {integrity: sha512-ZTWGm8VXQUTepV4aEmIgHxdY7JMtn57H3uYnM3HD+qR8fmAcpLPoJ9ffXaMWUwsjK6SeferQyDTRN3q3Jd5+mg==} + '@storybook/addon-onboarding@10.4.6': + resolution: {integrity: sha512-BzTu5LW5Ygwv0BbmQM0QohGS5Uoa8GjSxPcZFCQ1iQfizUukKnd72UIsPDZBKjVytO0DbZ3YoM1Ww5+KL6ZXEg==} peerDependencies: - storybook: ^10.4.4 + storybook: ^10.4.6 - '@storybook/addon-themes@10.4.4': - resolution: {integrity: sha512-VH443z7o/JO5K9QFVuB9IzwaMu0jEiq4ybpzTlAmt0ZUEqNBuM+ESBvkVMkZ5QeNghKrs/J9yvum2g2t94YR4Q==} + '@storybook/addon-themes@10.4.6': + resolution: {integrity: sha512-80d622oB9xWZs3VH4uywkLOA5L2DAx04lVouvCM4XH+pLnJElidoylOLm3i3ByvlGkRjCbB27OUVsW94IgyDrw==} peerDependencies: - storybook: ^10.4.4 + storybook: ^10.4.6 - '@storybook/addon-vitest@10.4.4': - resolution: {integrity: sha512-VPpBwf1Elr+0g33am8ZE6aHhLB+r1TPxUsnDuCVNhxGjRxMFyQkAE8+jPJFPvS/YIUGMbVXarzaV7PcI/sJuVQ==} + '@storybook/addon-vitest@10.4.6': + resolution: {integrity: sha512-VvskHge0GZy86LG6kcY5Ww34z8rDV8JBxqSdUpcJVsWfIvyX6MfAbqI76LlereSyBIJGZJZsqaLwRXsQoVY+0Q==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.4.4 - vitest: ^3.0.0 || ^4.0.0 + storybook: ^10.4.6 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4268,18 +4287,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.4.4': - resolution: {integrity: sha512-VyuZ4mEvhhVXjJa1qXMWKH8ohnas0rgEuJDf6u4aJ54XeENFebPUEAHde1Qo2PflJ4rUdVdXieOZzKbYwP5RAQ==} + '@storybook/builder-vite@10.4.6': + resolution: {integrity: sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg==} peerDependencies: - storybook: ^10.4.4 + storybook: ^10.4.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.4.4': - resolution: {integrity: sha512-1mzZyAwVUmAcw4WEUsJDVdSupkJf+Kf/f5uNAs4RzlBXA75P8YRkDKAb2EoMwsB5URiXFi9XoeAN/vWke0G6+w==} + '@storybook/csf-plugin@10.4.6': + resolution: {integrity: sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==} peerDependencies: esbuild: ^0.28.1 - rollup: 4.61.1 - storybook: ^10.4.4 + rollup: 4.62.2 + storybook: ^10.4.6 vite: '*' webpack: '*' peerDependenciesMeta: @@ -4301,15 +4320,15 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/nextjs-vite@10.4.4': - resolution: {integrity: sha512-pmihRfZSNLG6w+jQuqwv+vlC8JWGWfBnlHIFrle9HpNxqJDeKxKfjjDAjjgB+LUgbFz348TXbjzkZjJc5hFTOQ==} + '@storybook/nextjs-vite@10.4.6': + resolution: {integrity: sha512-o8vkNqJPY0oq5qGAcwjiyZoZUsfhk7eIU1mjgtYbNoJhA1/NshU7pIaFSTfFjMRs1i43Df8hqS5JmyjP8r+bUg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 next: ^14.1.0 || ^15.0.0 || ^16.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 typescript: '*' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: @@ -4320,36 +4339,36 @@ packages: typescript: optional: true - '@storybook/react-dom-shim@10.4.4': - resolution: {integrity: sha512-y6SObmoW78AydE6VfKQSUmCkuqiaMPy9LgMpMdMEyWfJ/pSxBDMIKycr9dlRMJP1cvNgByaJgrusWtA46ndSQw==} + '@storybook/react-dom-shim@10.4.6': + resolution: {integrity: sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.4.4': - resolution: {integrity: sha512-hXw1c9Jq2eFzwmJ3u9phmszbHoPjwPLYjcR1Grd6Xbe2g3bReGH35urm/fTZ0HNdjXAgQlUaXp2bWw6vz0BHQw==} + '@storybook/react-vite@10.4.6': + resolution: {integrity: sha512-0arEQtybqGYXHbXpTot+Wv9YtG+V5Vp43QayXavPKQ20M8mpEzhyCPKd0EhqMGSC1Z1UEt0hm365WUBhI9LfKA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/react@10.4.4': - resolution: {integrity: sha512-6K5/uHrvjswrueyVpUt6IWGuSgYCMtMOYyVs86XJZYqKBV3Pv7nGsGNH7YSMLAVQBZW4CQqm2etd5Op0GHY9Kg==} + '@storybook/react@10.4.6': + resolution: {integrity: sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.4 + storybook: ^10.4.6 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -4566,8 +4585,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-virtual@3.14.2': - resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==} + '@tanstack/react-virtual@3.14.3': + resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4575,8 +4594,8 @@ packages: '@tanstack/store@0.11.0': resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} - '@tanstack/virtual-core@3.17.0': - resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==} + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} '@teppeis/multimaps@3.0.0': resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} @@ -4611,20 +4630,20 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tsslint/cli@3.1.3': - resolution: {integrity: sha512-W+SZhIewVWG2aF51TkPv4EwY2PCYQzsUgRXmjtamYRs06QWJn7X67bf+ZroZMGTg2eeajy6+LKGWV39KGsDFOQ==} + '@tsslint/cli@3.1.4': + resolution: {integrity: sha512-svoLfFkoWmdsDrIRLllFnrxydfMjKKZ1UBjv7Sua1KjFkx6VaJ88+YGYqNiTbB/dDcU10qnSMXRavNTJ0fjBkQ==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: typescript: '*' - '@tsslint/compat-eslint@3.1.3': - resolution: {integrity: sha512-3dH4nocZS/WY+a8N0TGawISEmAMxWOqEqX0YD18eTbOo9YBHVAhrQmPFA2AoEeBxR5+N8xbVYxVJ1b6K8yE8CA==} + '@tsslint/compat-eslint@3.1.4': + resolution: {integrity: sha512-aMSOnAHC/sJFGM21I1/rFzwNLyfbW2BVRivsP1xDgRwrt3+WfLT/eVU2HMEtS8J6XVPNkQ4bKD4Uk1lRpYaNlA==} peerDependencies: typescript: '*' - '@tsslint/config@3.1.3': - resolution: {integrity: sha512-gquCvrctDv1n2T+gdja0jnoDm+U/mwwYU01iaNglt+MHO6VAZRyTJaxtu72IMTPwo73pOfiummFTHaLdifIXJQ==} + '@tsslint/config@3.1.4': + resolution: {integrity: sha512-6VcUimc170M1v3b0vmOhRW7NI/b7DqXB5Wpo+1wCNLprDTN1HwjsbfdFNm/nsd0jXWChNr/cJhmsnVp4xHDCKw==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: @@ -4636,12 +4655,12 @@ packages: tsl: optional: true - '@tsslint/core@3.1.3': - resolution: {integrity: sha512-X/XiTj3w4tZhy4yh/zvTR/Z0Nb68Ul/uiQGsdKE/ClUCK0Iu7wDpuO7PKH+yj16eg02+l7kD6ojhJKL7aXibaA==} + '@tsslint/core@3.1.4': + resolution: {integrity: sha512-C4bUPiZ6ZWemh/GIuH6RH+frGHLH7wh3HI3d4cEQiOUoICmFidEkdfq5YDI/Bfdeqol5Ezyp5GzyHaXq+wU2fA==} engines: {node: '>=22.6.0'} - '@tsslint/types@3.1.3': - resolution: {integrity: sha512-KskQ8bFj3DY0Esg8Fe9K3qZxiW7HOV3c+HuqwVGXR5s1LMmIooB2uG4Bb0Tpj7p5GMedhmfQjz3JoAWDa27FWA==} + '@tsslint/types@3.1.4': + resolution: {integrity: sha512-YMLUQwG/cGlvoCEg1WVAq0EmmoHOzJ/WkenKWP7tFfQnTk4wIyZgOMIQkvYBcREPin7OVbLlaAEOH9eTr8SUUA==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4814,6 +4833,9 @@ packages: '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4866,6 +4888,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.61.0': resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4873,22 +4903,45 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.61.0': resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.61.0': resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.61.0': resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.61.0': resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4896,16 +4949,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.61.0': resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.61.0': resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4913,54 +4983,65 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.61.0': resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-znTFp/M0SpN91/FM3FrsTycdTZr+bLszmHObm2LaR5sgo1c2wiK4qYsMnrhLeP0vSMD2LuiNg5J21501h7Aj6g==} + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-1qwEjLW1JCRkDYrS8OyWdfXoL9bNHV28kP3ouQxytZmNpghWSMKZutsxDjVVbnlsbydBdYqqZMttPuTUlm3y0A==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-7zXTq3SVw6XCLdm06VRIhMzayG032Ky14xCZ3isnlcm1KD/p4Ev4XL9TN9fZmIV0bVPGMQr4qGS7a2+te8x7lg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-/Z5Jx8V22RTuHSVR/QCbCT2eFq6NoYc5oLah5/yDoymZaTlmvuwuc50N6y9YXF0zAh+z4QGoyAqd561cAGSNOA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-yYiDEMT4ok7Wd1VtbyB+0YqBdTkU/yzE36edZCbfA/Ljt6L1xZMEn0a7XGlmRtRfwPGHfjtFRFsxrA2nc7jHnw==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-rz7JoRJE5zaL2RoTOHLUliz6UkDqjUngJEF1/GiSR032v8k8tp8GTezxFoosaCssQSf80cjA5wYup5zrCp4zIg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-xpA7nXiRE3fwvTUK2ibhkPr7hn6D4i3pB9WjlcX7CSPB2pYGMdHAsL55EAFlR3eNKyNM/geoLfqq4L4PIeOWsQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-wuqpmYeMkOvHEx+log3bi913JRacYRo7DwkABMbD5wQMEy2GCdp3461aQ5sGrRD/lL4rylZJxE5B5aQqyhkQYA==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-mZO9hIwXek9VO+N+gs6IKDOf0fPnrQg/SvaOsCDcDwsk7a60yPbacyY7IiXFnxhNgZDLsYqs8j9ojDGl6+Tp5g==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-8zhjBFu0RuL16iFr0TlUU8RRMdmvUY+NuSSxaVeitxmvORFoy0EwIaz+ZK9+xRhfHCIcugi6mEcTqEPv48X3oQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-JtMTHRt7auXTCvvOtcn7aX1gdCPByQl4c5vc8e1d+8PcqtMqeSpH1j0Vp06F9vugTA+YMIzFsJQA91sUK1oHeg==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-Bax5HY/6NUcbk7h+SLJIbRL8TvGRLP1mbvI9T4ah6wfYKRp3rwBlXHP3N+BEYdzOFTUlzBJLknxcjtxWd5cFPw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-aCZaFWHDt5n8B++F2FrTK2pF7muAFfkvi8qkUnC0N0SNBGdVnqlO28IbdjScIfI2JFOvgZtUoYWeATGWdrLXBA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-+revF2e9JCBx1rLQCX+ny8KuZ0Tl5de7+YpYd4PUNKgF5yPi9n2+TmXtci8/3yBl7QRyLWG1td28RMa/8eIynQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260613.1': - resolution: {integrity: sha512-RJ3YrYNoshFGVY862CwZ/WcICvMW0u9XtezI8AAU81I71Cr3/IJoHPUGsGKUr2gVcPfKRQiaI8YTndFzUGvSug==} + '@typescript/native-preview@7.0.0-dev.20260620.1': + resolution: {integrity: sha512-W5XiTTbIGZSAJUMJqynchbfcjPcoNn29U8dnd+FK+x+Mj9VT9NNTymZ0YAIf+hU2uYAxx3AGe6/U/2OF2TpQwg==} engines: {node: '>=16.20.0'} hasBin: true @@ -5022,16 +5103,27 @@ packages: react-server-dom-webpack: optional: true - '@vitest/browser@4.1.8': - resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} + '@vitest/browser-playwright@4.1.9': + resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==} peerDependencies: - vitest: 4.1.8 + playwright: '*' + vitest: 4.1.9 - '@vitest/coverage-v8@4.1.8': - resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + '@vitest/browser-preview@4.1.9': + resolution: {integrity: sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow==} peerDependencies: - '@vitest/browser': 4.1.8 - vitest: 4.1.8 + vitest: 4.1.9 + + '@vitest/browser@4.1.9': + resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} + peerDependencies: + vitest: 4.1.9 + + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -5043,17 +5135,23 @@ packages: '@typescript-eslint/eslint-plugin': '*' eslint: '>=8.57.0' typescript: '>=5.0.0' + vitest: 4.1.9 peerDependenciesMeta: '@typescript-eslint/eslint-plugin': optional: true typescript: optional: true + vitest: + optional: true '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5066,28 +5164,34 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} - '@voidzero-dev/vite-plus-core@0.1.24': - resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-core@0.2.1': + resolution: {integrity: sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.22.1 - '@tsdown/exe': 0.22.1 + '@tsdown/css': 0.22.3 + '@tsdown/exe': 0.22.3 '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.18 esbuild: ^0.28.1 @@ -5144,86 +5248,55 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': - resolution: {integrity: sha512-Hpo9W9piSFlEsJzGkwzfDXhJGrnYByxHXF7NVQZ7g+SLOprddtlfTeM8t+gq9dxcuq0RzM8ddMAhDQP/K3fZQA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-darwin-arm64@0.2.1': + resolution: {integrity: sha512-9AfN/5LKRks8gbTaHPiQHT0L4yboy2xB6x6vvCRWxQMWxPS6/ZJLf5kUIZeE7I1z33AEyLKKkDscsZZVMgMLgg==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.1.24': - resolution: {integrity: sha512-SwnnnZrEFBiU5iKlh/CZAVwn0RFt/Udrvt3kFLtdRxMtN5bKaqTFVA2H8Y/FPCWp1QX9bs4V9ZIAeXAk06zLkw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-darwin-x64@0.2.1': + resolution: {integrity: sha512-Q1vyimRbf4M82qIQSWRyr7NJaH9ag5G7vVEfGVVJlQHNprI+Q8zj2Phcs/PGf6QcyjcL8UclLznQTHU9NgnKZw==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': - resolution: {integrity: sha512-ImM3eqDki4DpRuHjW6dEh4St8zvbcfOMR7KQZJX42ArriCLQ/QdaYhDRRbcDi27XsOBqRxm2eqUUEymPrYIHpA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.1': + resolution: {integrity: sha512-WHW3DziqedRfhJ2upq6kC4y/pmdQWYt322DVB7+4Xb4oOa/CT9GtnSrWIiXVJ4PSO42v54+YsSTKPH2HC5RbtA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': - resolution: {integrity: sha512-gj4mzbob/ls8Zs7iTuF9Gr0EFFF7tdpDiPxDPBkH8tJP5OkHABlzWUwJhU+9xxcUbTaXqpHDw68Mil7jm5dpMg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1': + resolution: {integrity: sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': - resolution: {integrity: sha512-x7IYK7lI+WuF1n3jSzEYU6FgJxPX/R0rDmTTsOutooGGCU7uShZvfZqIoiTXK0eFnJU5ij5BfBgenenUfsaT/A==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1': + resolution: {integrity: sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': - resolution: {integrity: sha512-JCy2w0eSVUlWQlggK5T47MnL+j0o4EY7hLskINVI8gi+aixQF4xnYBDobz0lbxkqz3/IfiLyXUx6TcU3thcsGQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1': + resolution: {integrity: sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-test@0.1.24': - resolution: {integrity: sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': - resolution: {integrity: sha512-G+/lhLKVjyn3FmgXX8jeWgq7RcE5O1kdR7QyFayQOdlMX/ZRkvUwQD7bFaqhKzgJM6Oj3a1FH3HQPYk5QOYuCQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1': + resolution: {integrity: sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': - resolution: {integrity: sha512-b0e5XohEV1w/RdzAtv8/Hm6tvHPXouPtBNsljjW/lDJZq3NCLND5s6lqe8H4IenrgmKSoqakHWtlqJqM36cFbw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.1': + resolution: {integrity: sha512-5sOEwEoU5PW7ObmJ5VCakU09Oh14rYCoLQJkFqvOph6PK30lN5iqWGk0KigEyfcd7Zv+fZg9EmcERDol/3Xl9w==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} cpu: [x64] os: [win32] @@ -5537,6 +5610,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.1: resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} engines: {node: '>=10'} @@ -5650,8 +5727,8 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc - code-inspector-plugin@1.6.0: - resolution: {integrity: sha512-pH5sQoL5VGKXo92yimfzdF8yHWGU3hC8uA8kovLR3EB0WSJ7TfC+AZaPryLI2n5F2dSWUgaCv5zuoNvDvKHQBQ==} + code-inspector-plugin@1.6.1: + resolution: {integrity: sha512-XsGzQ2Kkrn2xdZtwviORKOVY4H8ixe95VSxkjUfzdWGhl9z3UVA2tci8o/1juJm+vaIxcVjlyefJOoTKjqhQcw==} collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -5729,8 +5806,8 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - cron-parser@5.5.0: - resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + cron-parser@5.6.0: + resolution: {integrity: sha512-6159NDv4eDOjXYDmMTkvUtaVcIrNZI779ydTMOfDdi9fSjhPqmySx0icCHX2+nOyXgNSw6sFCsgLKxYs/2KPuQ==} engines: {node: '>=18'} cross-spawn@7.0.6: @@ -6069,8 +6146,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.10: - resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6224,6 +6301,7 @@ packages: esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} + hasBin: true escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -6437,16 +6515,16 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-sonarjs@4.0.3: - resolution: {integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==} + eslint-plugin-sonarjs@4.1.0: + resolution: {integrity: sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==} peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 - eslint-plugin-storybook@10.4.4: - resolution: {integrity: sha512-RqEDQJRaeTdfSDRO9W2sKui4oLax6cSuKU/6vFXbiyEmyaACktkQce9DYEf2i5+MZ07y/X/1G0UZeY+WrPQlIg==} + eslint-plugin-storybook@10.4.6: + resolution: {integrity: sha512-CfGSXn6zFspeYTU8R7v797MOmJFj8xc6MWf/oGuRwbKeMoSwnliR+OlXSjMZYM1D6gfmwiuH1VX58LSHdn+ZPg==} peerDependencies: eslint: '>=8' - storybook: ^10.4.4 + storybook: ^10.4.6 eslint-plugin-toml@1.3.1: resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} @@ -6585,6 +6663,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -6685,8 +6767,8 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - foxact@0.3.5: - resolution: {integrity: sha512-pcn1Ro445aOooB9Vk6NfbUMpiMHOgZ/Nki9MmVslVfRYjvmySLtssgALYyzspt7j2dNbpxxCyClaYSL2I4iJTg==} + foxact@0.3.7: + resolution: {integrity: sha512-U9+boW5SGK8TMuJ/21s7yfszXJCqVoUj8K9Gqmpe/uqiOG4wtPILxsONqrh0IizteGzGwiDa7oiPX2hn5AJtog==} peerDependencies: react: '*' react-dom: '*' @@ -6839,8 +6921,8 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - happy-dom@20.10.3: - resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} has-ansi@6.0.2: @@ -6923,8 +7005,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.26: + resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.2: @@ -6990,6 +7072,7 @@ packages: image-size@2.0.2: resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} engines: {node: '>=16.x'} + hasBin: true immer@11.1.8: resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} @@ -7331,8 +7414,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.16.1: - resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} + knip@6.17.1: + resolution: {integrity: sha512-HcQsZSQ4Ymhuay4BVzJtM5pFZNDSomYYqcNCZOSITPQh9g18a09DqziWAxSt2G+BH9wGlG+0ZjWpEnaFlnKseQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -7493,8 +7576,8 @@ packages: loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - loro-crdt@1.13.2: - resolution: {integrity: sha512-Br9tZZk9x/HP83By9RvOCqzWh8v8tnOhVlR6/ibYNtLSmysO7ZgwzjNpqsCABqaSOcGC7TBkx5sG8tfosdJMQA==} + loro-crdt@1.13.5: + resolution: {integrity: sha512-U6L88EbSdv8TeFcXyKew0BySRJnOCPsgU8p38PhzGjQlfD8TC1pLRk1J7X5OYecr8Z9ijD1J13JEf+qIvw3ztg==} loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -8133,15 +8216,15 @@ packages: resolution: {integrity: sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-parser@0.133.0: - resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==} + oxc-parser@0.135.0: + resolution: {integrity: sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} - oxfmt@0.52.0: - resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} + oxfmt@0.55.0: + resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8155,9 +8238,10 @@ packages: oxlint-tsgolint@0.23.0: resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + hasBin: true - oxlint@1.67.0: - resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} + oxlint@1.70.0: + resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8278,23 +8362,21 @@ packages: pinyin-pro@3.28.1: resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} engines: {node: '>=18'} + hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} engines: {node: '>=18'} + hasBin: true pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} @@ -8787,8 +8869,8 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - sharp@0.35.1: - resolution: {integrity: sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} shebang-command@2.0.0: @@ -8807,6 +8889,9 @@ packages: resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==} engines: {node: '>=20'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -8882,6 +8967,9 @@ packages: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -8903,8 +8991,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.4.4: - resolution: {integrity: sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA==} + storybook@10.4.6: + resolution: {integrity: sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -9089,6 +9177,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -9105,11 +9197,12 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.4.2: - resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} - tldts@7.4.2: - resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -9158,6 +9251,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -9235,8 +9329,8 @@ packages: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} - unbash@3.0.0: - resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} + unbash@4.0.1: + resolution: {integrity: sha512-1ajSo3813sDoVIHx4inJdUS4l5L2ic5cFiddemPiyjb/PZEoBAhFwHtbaEdRDFxbAKy7FCG7s5ww3/uCFawuIA==} engines: {node: '>=14'} unbox-primitive@1.1.0: @@ -9246,14 +9340,14 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.26.0: - resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} - engines: {node: '>=20.18.1'} - undici@7.27.2: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unicode-trie@2.0.0: resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} @@ -9383,8 +9477,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true valibot@1.4.1: resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==} @@ -9406,8 +9501,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@0.1.2: - resolution: {integrity: sha512-ITrJGE7UwOanhz2Vfhkn44BSOskOrPxISjhJ3PbuFd8vEXzMNy6O2mOsFqWAatnFC4fMEanLVth5LAcxwkdH2A==} + vinext@0.1.6: + resolution: {integrity: sha512-uNqbo86PdaKcHCWpGcYOimQ2g1sS86um0InilA8bGjz51oktCssX6jWnQdOKYfOzYYOYYTW9y4N8vfC4vyIx6w==} engines: {node: '>=22'} hasBin: true peerDependencies: @@ -9449,9 +9544,18 @@ packages: storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - vite-plus@0.1.24: - resolution: {integrity: sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==} - engines: {node: ^20.19.0 || >=22.12.0} + vite-plus@0.2.1: + resolution: {integrity: sha512-q5q/Y38UkWFsNg1JO+RyRdPUqoewaSqIlMyK2p83GKNUvf4D38Ntb3PToRTDZbTRh7mWt+B+d0DQBv4nCDpMcQ==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} @@ -9481,7 +9585,7 @@ packages: '@types/react-dom': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - vitest: ^4.0.0 + vitest: 4.1.9 peerDependenciesMeta: '@types/react': optional: true @@ -9491,7 +9595,48 @@ packages: vitest-canvas-mock@1.1.4: resolution: {integrity: sha512-4boWHY+STwAxGl1+uwakNNoQky5EjPLC8HuponXNoAscYyT1h/F7RUvTkl4IyF/MiWr3V8Q626je3Iel3eArqA==} peerDependencies: - vitest: ^3.0.0 || ^4.0.0 + vitest: 4.1.9 + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} @@ -9559,6 +9704,11 @@ packages: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -9854,17 +10004,73 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@types/node@25.9.3)(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.9)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.4.0 - '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)(vitest@4.1.9) + ansis: 4.3.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-antfu: 3.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-n: 18.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-no-only-tests: 3.4.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) + eslint-plugin-yml: 3.3.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-processor-vue-blocks: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + yaml-eslint-parser: 2.0.0 + optionalDependencies: + '@eslint-react/eslint-plugin': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@next/eslint-plugin-next': 16.2.9 + eslint-plugin-jsx-a11y: 6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-react-refresh: 0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - ts-declaration-location + - typescript + - vitest + + '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)(vitest@4.1.9)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.4.0 + '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)) '@eslint/markdown': 8.0.1 '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9) ansis: 4.3.0 cac: 7.0.0 eslint: 10.5.0(jiti@2.7.0) @@ -9872,7 +10078,7 @@ snapshots: eslint-flat-config-utils: 3.2.0 eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-antfu: 3.2.3(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)) + eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.7.0)) @@ -9899,126 +10105,16 @@ snapshots: eslint-plugin-jsx-a11y: 6.10.2(eslint@10.5.0(jiti@2.7.0)) eslint-plugin-react-refresh: 0.5.3(eslint@10.5.0(jiti@2.7.0)) transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - '@eslint/json' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - '@typescript-eslint/rule-tester' - '@typescript-eslint/typescript-estree' - '@typescript-eslint/utils' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - '@vue/compiler-sfc' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - oxlint - - publint - - sass - - sass-embedded - - stylus - - sugarss - supports-color - - terser - ts-declaration-location - - tsx - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - - '@antfu/eslint-config@9.0.0(b376e15be293d4e014f0f69f32d1fb4a)': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@clack/prompts': 1.4.0 - '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) - '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - '@eslint/markdown': 8.0.1 - '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - ansis: 4.3.0 - cac: 7.0.0 - eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-flat-config-utils: 3.2.0 - eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-antfu: 3.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-n: 18.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) - eslint-plugin-yml: 3.3.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-processor-vue-blocks: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - globals: 17.6.0 - local-pkg: 1.1.2 - parse-gitignore: 2.0.0 - toml-eslint-parser: 1.0.3 - vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - yaml-eslint-parser: 2.0.0 - optionalDependencies: - '@eslint-react/eslint-plugin': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@next/eslint-plugin-next': 16.2.9 - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-react-refresh: 0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@eslint/json' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@typescript-eslint/rule-tester' - - '@typescript-eslint/typescript-estree' - - '@typescript-eslint/utils' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - '@vue/compiler-sfc' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - oxlint - - publint - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - ts-declaration-location - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml + - vitest '@antfu/install-pkg@1.1.0': dependencies: @@ -10061,14 +10157,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -10085,8 +10173,6 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-module-imports@7.29.7': @@ -10105,8 +10191,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -10120,40 +10204,18 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 '@babel/runtime@7.29.2': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -10166,11 +10228,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -10207,12 +10264,12 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@chromatic-com/storybook@5.2.1(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@chromatic-com/storybook@5.2.1(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 16.10.0 jsonfile: 6.2.0 - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -10231,7 +10288,7 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 - '@code-inspector/core@1.6.0(supports-color@10.2.2)': + '@code-inspector/core@1.6.1(supports-color@10.2.2)': dependencies: '@vue/compiler-dom': 3.5.31 chalk: 4.1.2 @@ -10241,35 +10298,35 @@ snapshots: transitivePeerDependencies: - supports-color - '@code-inspector/esbuild@1.6.0': + '@code-inspector/esbuild@1.6.1': dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) + '@code-inspector/core': 1.6.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@code-inspector/mako@1.6.0': + '@code-inspector/mako@1.6.1': dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) + '@code-inspector/core': 1.6.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@code-inspector/turbopack@1.6.0': + '@code-inspector/turbopack@1.6.1': dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) - '@code-inspector/webpack': 1.6.0 + '@code-inspector/core': 1.6.1(supports-color@10.2.2) + '@code-inspector/webpack': 1.6.1 transitivePeerDependencies: - supports-color - '@code-inspector/vite@1.6.0': + '@code-inspector/vite@1.6.1': dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) + '@code-inspector/core': 1.6.1(supports-color@10.2.2) chalk: 4.1.1 transitivePeerDependencies: - supports-color - '@code-inspector/webpack@1.6.0': + '@code-inspector/webpack@1.6.1': dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) + '@code-inspector/core': 1.6.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -10388,23 +10445,23 @@ snapshots: perfect-debounce: 2.1.0 tinyexec: 1.2.3 - '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: empathic: 2.0.0 module-replacements: 3.0.0-beta.7 semver: 7.8.4 optionalDependencies: eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: empathic: 2.0.0 module-replacements: 3.0.0-beta.7 semver: 7.8.4 optionalDependencies: eslint: 10.5.0(jiti@2.7.0) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.3.1)': dependencies: @@ -10567,9 +10624,9 @@ snapshots: '@eslint-react/ast@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) string-ts: 2.3.1 typescript: 6.0.3 @@ -10579,9 +10636,9 @@ snapshots: '@eslint-react/ast@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) string-ts: 2.3.1 typescript: 6.0.3 @@ -10595,9 +10652,9 @@ snapshots: '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10612,9 +10669,9 @@ snapshots: '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10652,7 +10709,7 @@ snapshots: '@eslint-react/eslint@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: @@ -10661,7 +10718,7 @@ snapshots: '@eslint-react/eslint@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -10673,8 +10730,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10688,8 +10745,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10699,7 +10756,7 @@ snapshots: '@eslint-react/shared@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10711,7 +10768,7 @@ snapshots: '@eslint-react/shared@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10723,9 +10780,9 @@ snapshots: dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10737,9 +10794,9 @@ snapshots: dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -10923,9 +10980,9 @@ snapshots: '@hey-api/types@0.1.4': {} - '@hono/node-server@2.0.4(hono@4.12.25)': + '@hono/node-server@2.0.5(hono@4.12.26)': dependencies: - hono: 4.12.25 + hono: 4.12.26 '@humanfs/core@0.19.1': {} @@ -10988,9 +11045,9 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-arm64@0.35.1': + '@img/sharp-darwin-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.0 + '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true '@img/sharp-darwin-x64@0.34.5': @@ -10998,74 +11055,74 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.35.1': + '@img/sharp-darwin-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.0 + '@img/sharp-libvips-darwin-x64': 1.3.1 optional: true - '@img/sharp-freebsd-wasm32@0.35.1': + '@img/sharp-freebsd-wasm32@0.35.2': dependencies: - '@img/sharp-wasm32': 0.35.1 + '@img/sharp-wasm32': 0.35.2 optional: true '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.3.0': + '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.0': + '@img/sharp-libvips-darwin-x64@1.3.1': optional: true '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.0': + '@img/sharp-libvips-linux-arm64@1.3.1': optional: true '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.0': + '@img/sharp-libvips-linux-arm@1.3.1': optional: true '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.0': + '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.0': + '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.0': + '@img/sharp-libvips-linux-s390x@1.3.1': optional: true '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.0': + '@img/sharp-libvips-linux-x64@1.3.1': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.0': + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.0': + '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true '@img/sharp-linux-arm64@0.34.5': @@ -11073,9 +11130,9 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm64@0.35.1': + '@img/sharp-linux-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.0 + '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true '@img/sharp-linux-arm@0.34.5': @@ -11083,9 +11140,9 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-arm@0.35.1': + '@img/sharp-linux-arm@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.0 + '@img/sharp-libvips-linux-arm': 1.3.1 optional: true '@img/sharp-linux-ppc64@0.34.5': @@ -11093,9 +11150,9 @@ snapshots: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.35.1': + '@img/sharp-linux-ppc64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.0 + '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true '@img/sharp-linux-riscv64@0.34.5': @@ -11103,9 +11160,9 @@ snapshots: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-riscv64@0.35.1': + '@img/sharp-linux-riscv64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.0 + '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true '@img/sharp-linux-s390x@0.34.5': @@ -11113,9 +11170,9 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.35.1': + '@img/sharp-linux-s390x@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.0 + '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true '@img/sharp-linux-x64@0.34.5': @@ -11123,9 +11180,9 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linux-x64@0.35.1': + '@img/sharp-linux-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.0 + '@img/sharp-libvips-linux-x64': 1.3.1 optional: true '@img/sharp-linuxmusl-arm64@0.34.5': @@ -11133,9 +11190,9 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.1': + '@img/sharp-linuxmusl-arm64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true '@img/sharp-linuxmusl-x64@0.34.5': @@ -11143,9 +11200,9 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.1': + '@img/sharp-linuxmusl-x64@0.35.2': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.0 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true '@img/sharp-wasm32@0.34.5': @@ -11153,43 +11210,43 @@ snapshots: '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-wasm32@0.35.1': + '@img/sharp-wasm32@0.35.2': dependencies: '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-webcontainers-wasm32@0.35.1': + '@img/sharp-webcontainers-wasm32@0.35.2': dependencies: - '@img/sharp-wasm32': 0.35.1 + '@img/sharp-wasm32': 0.35.2 optional: true '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-arm64@0.35.1': + '@img/sharp-win32-arm64@0.35.2': optional: true '@img/sharp-win32-ia32@0.34.5': optional: true - '@img/sharp-win32-ia32@0.35.1': + '@img/sharp-win32-ia32@0.35.2': optional: true '@img/sharp-win32-x64@0.34.5': optional: true - '@img/sharp-win32-x64@0.35.1': + '@img/sharp-win32-x64@0.35.2': optional: true '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3)': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3)': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: typescript: 6.0.3 @@ -11649,7 +11706,7 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.132.0': optional: true - '@oxc-parser/binding-android-arm-eabi@0.133.0': + '@oxc-parser/binding-android-arm-eabi@0.135.0': optional: true '@oxc-parser/binding-android-arm64@0.127.0': @@ -11658,7 +11715,7 @@ snapshots: '@oxc-parser/binding-android-arm64@0.132.0': optional: true - '@oxc-parser/binding-android-arm64@0.133.0': + '@oxc-parser/binding-android-arm64@0.135.0': optional: true '@oxc-parser/binding-darwin-arm64@0.127.0': @@ -11667,7 +11724,7 @@ snapshots: '@oxc-parser/binding-darwin-arm64@0.132.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.133.0': + '@oxc-parser/binding-darwin-arm64@0.135.0': optional: true '@oxc-parser/binding-darwin-x64@0.127.0': @@ -11676,7 +11733,7 @@ snapshots: '@oxc-parser/binding-darwin-x64@0.132.0': optional: true - '@oxc-parser/binding-darwin-x64@0.133.0': + '@oxc-parser/binding-darwin-x64@0.135.0': optional: true '@oxc-parser/binding-freebsd-x64@0.127.0': @@ -11685,7 +11742,7 @@ snapshots: '@oxc-parser/binding-freebsd-x64@0.132.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.133.0': + '@oxc-parser/binding-freebsd-x64@0.135.0': optional: true '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': @@ -11694,7 +11751,7 @@ snapshots: '@oxc-parser/binding-linux-arm-gnueabihf@0.132.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.135.0': optional: true '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': @@ -11703,7 +11760,7 @@ snapshots: '@oxc-parser/binding-linux-arm-musleabihf@0.132.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.135.0': optional: true '@oxc-parser/binding-linux-arm64-gnu@0.127.0': @@ -11712,7 +11769,7 @@ snapshots: '@oxc-parser/binding-linux-arm64-gnu@0.132.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': + '@oxc-parser/binding-linux-arm64-gnu@0.135.0': optional: true '@oxc-parser/binding-linux-arm64-musl@0.127.0': @@ -11721,7 +11778,7 @@ snapshots: '@oxc-parser/binding-linux-arm64-musl@0.132.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.133.0': + '@oxc-parser/binding-linux-arm64-musl@0.135.0': optional: true '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': @@ -11730,7 +11787,7 @@ snapshots: '@oxc-parser/binding-linux-ppc64-gnu@0.132.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.135.0': optional: true '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': @@ -11739,7 +11796,7 @@ snapshots: '@oxc-parser/binding-linux-riscv64-gnu@0.132.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.135.0': optional: true '@oxc-parser/binding-linux-riscv64-musl@0.127.0': @@ -11748,7 +11805,7 @@ snapshots: '@oxc-parser/binding-linux-riscv64-musl@0.132.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': + '@oxc-parser/binding-linux-riscv64-musl@0.135.0': optional: true '@oxc-parser/binding-linux-s390x-gnu@0.127.0': @@ -11757,7 +11814,7 @@ snapshots: '@oxc-parser/binding-linux-s390x-gnu@0.132.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': + '@oxc-parser/binding-linux-s390x-gnu@0.135.0': optional: true '@oxc-parser/binding-linux-x64-gnu@0.127.0': @@ -11766,7 +11823,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu@0.132.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.133.0': + '@oxc-parser/binding-linux-x64-gnu@0.135.0': optional: true '@oxc-parser/binding-linux-x64-musl@0.127.0': @@ -11775,7 +11832,7 @@ snapshots: '@oxc-parser/binding-linux-x64-musl@0.132.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.133.0': + '@oxc-parser/binding-linux-x64-musl@0.135.0': optional: true '@oxc-parser/binding-openharmony-arm64@0.127.0': @@ -11784,7 +11841,7 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.132.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.133.0': + '@oxc-parser/binding-openharmony-arm64@0.135.0': optional: true '@oxc-parser/binding-wasm32-wasi@0.127.0': @@ -11801,7 +11858,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@oxc-parser/binding-wasm32-wasi@0.133.0': + '@oxc-parser/binding-wasm32-wasi@0.135.0': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -11814,7 +11871,7 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.132.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': + '@oxc-parser/binding-win32-arm64-msvc@0.135.0': optional: true '@oxc-parser/binding-win32-ia32-msvc@0.127.0': @@ -11823,7 +11880,7 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc@0.132.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': + '@oxc-parser/binding-win32-ia32-msvc@0.135.0': optional: true '@oxc-parser/binding-win32-x64-msvc@0.127.0': @@ -11832,16 +11889,18 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.132.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.133.0': + '@oxc-parser/binding-win32-x64-msvc@0.135.0': optional: true - '@oxc-project/runtime@0.133.0': {} + '@oxc-project/runtime@0.136.0': {} '@oxc-project/types@0.127.0': {} '@oxc-project/types@0.132.0': {} - '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.135.0': {} + + '@oxc-project/types@0.136.0': {} '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true @@ -11904,61 +11963,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true - '@oxfmt/binding-android-arm-eabi@0.52.0': + '@oxfmt/binding-android-arm-eabi@0.55.0': optional: true - '@oxfmt/binding-android-arm64@0.52.0': + '@oxfmt/binding-android-arm64@0.55.0': optional: true - '@oxfmt/binding-darwin-arm64@0.52.0': + '@oxfmt/binding-darwin-arm64@0.55.0': optional: true - '@oxfmt/binding-darwin-x64@0.52.0': + '@oxfmt/binding-darwin-x64@0.55.0': optional: true - '@oxfmt/binding-freebsd-x64@0.52.0': + '@oxfmt/binding-freebsd-x64@0.55.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.52.0': + '@oxfmt/binding-linux-arm64-gnu@0.55.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.52.0': + '@oxfmt/binding-linux-arm64-musl@0.55.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.52.0': + '@oxfmt/binding-linux-riscv64-musl@0.55.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.52.0': + '@oxfmt/binding-linux-s390x-gnu@0.55.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.52.0': + '@oxfmt/binding-linux-x64-gnu@0.55.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.52.0': + '@oxfmt/binding-linux-x64-musl@0.55.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.52.0': + '@oxfmt/binding-openharmony-arm64@0.55.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.52.0': + '@oxfmt/binding-win32-arm64-msvc@0.55.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.52.0': + '@oxfmt/binding-win32-ia32-msvc@0.55.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.52.0': + '@oxfmt/binding-win32-x64-msvc@0.55.0': optional: true '@oxlint-tsgolint/darwin-arm64@0.23.0': @@ -11979,70 +12038,70 @@ snapshots: '@oxlint-tsgolint/win32-x64@0.23.0': optional: true - '@oxlint/binding-android-arm-eabi@1.67.0': + '@oxlint/binding-android-arm-eabi@1.70.0': optional: true - '@oxlint/binding-android-arm64@1.67.0': + '@oxlint/binding-android-arm64@1.70.0': optional: true - '@oxlint/binding-darwin-arm64@1.67.0': + '@oxlint/binding-darwin-arm64@1.70.0': optional: true - '@oxlint/binding-darwin-x64@1.67.0': + '@oxlint/binding-darwin-x64@1.70.0': optional: true - '@oxlint/binding-freebsd-x64@1.67.0': + '@oxlint/binding-freebsd-x64@1.70.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + '@oxlint/binding-linux-arm-gnueabihf@1.70.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.67.0': + '@oxlint/binding-linux-arm-musleabihf@1.70.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.67.0': + '@oxlint/binding-linux-arm64-gnu@1.70.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.67.0': + '@oxlint/binding-linux-arm64-musl@1.70.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.67.0': + '@oxlint/binding-linux-ppc64-gnu@1.70.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.67.0': + '@oxlint/binding-linux-riscv64-gnu@1.70.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.67.0': + '@oxlint/binding-linux-riscv64-musl@1.70.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.67.0': + '@oxlint/binding-linux-s390x-gnu@1.70.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.67.0': + '@oxlint/binding-linux-x64-gnu@1.70.0': optional: true - '@oxlint/binding-linux-x64-musl@1.67.0': + '@oxlint/binding-linux-x64-musl@1.70.0': optional: true - '@oxlint/binding-openharmony-arm64@1.67.0': + '@oxlint/binding-openharmony-arm64@1.70.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.67.0': + '@oxlint/binding-win32-arm64-msvc@1.70.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.67.0': + '@oxlint/binding-win32-ia32-msvc@1.70.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.67.0': + '@oxlint/binding-win32-x64-msvc@1.70.0': optional: true - '@oxlint/plugins@1.61.0': {} + '@oxlint/plugins@1.68.0': {} '@pkgr/core@0.2.9': {} - '@playwright/test@1.60.0': + '@playwright/test@1.61.0': dependencies: - playwright: 1.60.0 + playwright: 1.61.0 '@polka/url@1.0.0-next.29': {} @@ -12306,40 +12365,40 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 - '@sentry-internal/browser-utils@10.57.0': + '@sentry/browser-utils@10.59.0': dependencies: - '@sentry/core': 10.57.0 + '@sentry/core': 10.59.0 - '@sentry-internal/feedback@10.57.0': + '@sentry/browser@10.59.0': dependencies: - '@sentry/core': 10.57.0 + '@sentry/browser-utils': 10.59.0 + '@sentry/core': 10.59.0 + '@sentry/feedback': 10.59.0 + '@sentry/replay': 10.59.0 + '@sentry/replay-canvas': 10.59.0 - '@sentry-internal/replay-canvas@10.57.0': + '@sentry/core@10.59.0': {} + + '@sentry/feedback@10.59.0': dependencies: - '@sentry-internal/replay': 10.57.0 - '@sentry/core': 10.57.0 + '@sentry/core': 10.59.0 - '@sentry-internal/replay@10.57.0': + '@sentry/react@10.59.0(react@19.2.7)': dependencies: - '@sentry-internal/browser-utils': 10.57.0 - '@sentry/core': 10.57.0 - - '@sentry/browser@10.57.0': - dependencies: - '@sentry-internal/browser-utils': 10.57.0 - '@sentry-internal/feedback': 10.57.0 - '@sentry-internal/replay': 10.57.0 - '@sentry-internal/replay-canvas': 10.57.0 - '@sentry/core': 10.57.0 - - '@sentry/core@10.57.0': {} - - '@sentry/react@10.57.0(react@19.2.7)': - dependencies: - '@sentry/browser': 10.57.0 - '@sentry/core': 10.57.0 + '@sentry/browser': 10.59.0 + '@sentry/core': 10.59.0 react: 19.2.7 + '@sentry/replay-canvas@10.59.0': + dependencies: + '@sentry/core': 10.59.0 + '@sentry/replay': 10.59.0 + + '@sentry/replay@10.59.0': + dependencies: + '@sentry/browser-utils': 10.59.0 + '@sentry/core': 10.59.0 + '@shikijs/core@4.2.0': dependencies: '@shikijs/primitive': 4.2.0 @@ -12393,21 +12452,21 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-a11y@10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-a11y@10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.12.0 - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - '@storybook/addon-docs@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-docs@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/csf-plugin': 10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@storybook/react-dom-shim': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 optionalDependencies: '@types/react': 19.2.17 @@ -12418,53 +12477,55 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.4.4(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-links@10.4.6(@types/react@19.2.17)(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.17 react: 19.2.7 - '@storybook/addon-onboarding@10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-onboarding@10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - '@storybook/addon-themes@10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-themes@10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.4.4(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-vitest@10.4.6(@vitest/browser-playwright@4.1.9)(@vitest/browser@4.1.9)(@vitest/runner@4.1.9)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(vitest@4.1.9)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-playwright': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(playwright@1.61.0)(vitest@4.1.9) + '@vitest/runner': 4.1.9 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) transitivePeerDependencies: - react - react-dom - '@storybook/builder-vite@10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/builder-vite@10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - '@storybook/csf-plugin': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/csf-plugin@10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' '@storybook/global@5.0.0': {} @@ -12473,18 +12534,18 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@storybook/nextjs-vite@10.4.4(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3)': + '@storybook/nextjs-vite@10.4.6(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@storybook/builder-vite': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) - '@storybook/react': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) - '@storybook/react-vite': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) - next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@storybook/builder-vite': 10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + '@storybook/react-vite': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -12497,30 +12558,30 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/react-dom-shim@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@storybook/react-vite@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': + '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) '@rollup/pluginutils': 5.3.0 - '@storybook/builder-vite': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) - '@storybook/react': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + '@storybook/builder-vite': 10.4.6(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.7 react-docgen: 8.0.3 react-dom: 19.2.7(react@19.2.7) resolve: 1.22.11 - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) tsconfig-paths: 4.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12530,15 +12591,15 @@ snapshots: - typescript - webpack - '@storybook/react@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': + '@storybook/react@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) react: 19.2.7 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -12669,12 +12730,12 @@ snapshots: postcss-selector-parser: 6.1.4 tailwindcss: 4.3.1 - '@tailwindcss/vite@4.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.1 '@tailwindcss/oxide': 4.3.1 tailwindcss: 4.3.1 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -12728,21 +12789,21 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) - '@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/virtual-core': 3.17.0 + '@tanstack/virtual-core': 3.17.1 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) '@tanstack/store@0.11.0': {} - '@tanstack/virtual-core@3.17.0': {} + '@tanstack/virtual-core@3.17.1': {} '@teppeis/multimaps@3.0.0': {} '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -12774,11 +12835,11 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsslint/cli@3.1.3(@tsslint/compat-eslint@3.1.3(typescript@6.0.3))(typescript@6.0.3)': + '@tsslint/cli@3.1.4(@tsslint/compat-eslint@3.1.4(typescript@6.0.3))(typescript@6.0.3)': dependencies: - '@tsslint/config': 3.1.3(@tsslint/compat-eslint@3.1.3(typescript@6.0.3)) - '@tsslint/core': 3.1.3 - '@tsslint/types': 3.1.3 + '@tsslint/config': 3.1.4(@tsslint/compat-eslint@3.1.4(typescript@6.0.3)) + '@tsslint/core': 3.1.4 + '@tsslint/types': 3.1.4 '@volar/language-core': 2.4.28 '@volar/language-hub': 0.0.1 '@volar/typescript': 2.4.28 @@ -12788,25 +12849,25 @@ snapshots: - '@tsslint/compat-eslint' - tsl - '@tsslint/compat-eslint@3.1.3(typescript@6.0.3)': + '@tsslint/compat-eslint@3.1.4(typescript@6.0.3)': dependencies: - '@tsslint/types': 3.1.3 + '@tsslint/types': 3.1.4 esquery: 1.7.0 typescript: 6.0.3 - '@tsslint/config@3.1.3(@tsslint/compat-eslint@3.1.3(typescript@6.0.3))': + '@tsslint/config@3.1.4(@tsslint/compat-eslint@3.1.4(typescript@6.0.3))': dependencies: - '@tsslint/types': 3.1.3 + '@tsslint/types': 3.1.4 minimatch: 10.2.5 optionalDependencies: - '@tsslint/compat-eslint': 3.1.3(typescript@6.0.3) + '@tsslint/compat-eslint': 3.1.4(typescript@6.0.3) - '@tsslint/core@3.1.3': + '@tsslint/core@3.1.4': dependencies: - '@tsslint/types': 3.1.3 + '@tsslint/types': 3.1.4 minimatch: 10.2.5 - '@tsslint/types@3.1.3': {} + '@tsslint/types@3.1.4': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -12817,24 +12878,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -13005,12 +13066,17 @@ snapshots: '@types/node@25.9.3': dependencies: undici-types: 7.24.6 + optional: true + + '@types/node@25.9.4': + dependencies: + undici-types: 7.24.6 '@types/normalize-package-data@2.4.4': {} '@types/papaparse@5.5.2': dependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 '@types/qs@6.15.1': {} @@ -13036,7 +13102,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 '@types/yauzl@2.10.3': dependencies: @@ -13045,12 +13111,12 @@ snapshots: '@types/zen-observable@0.8.3': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.61.0 eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) @@ -13061,12 +13127,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.61.0 eslint: 10.5.0(jiti@2.7.0) @@ -13077,7 +13143,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 10.5.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/types': 8.61.0 @@ -13089,7 +13171,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/types': 8.61.0 @@ -13101,6 +13183,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) @@ -13110,16 +13204,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.61.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.61.0': dependencies: '@typescript-eslint/types': 8.61.0 '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.61.0 '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) @@ -13131,7 +13243,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.61.0 '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) @@ -13143,8 +13255,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + + '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.61.1': {} + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) @@ -13154,7 +13293,22 @@ snapshots: debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 semver: 7.8.4 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -13182,41 +13336,68 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.61.0': dependencies: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260613.1': + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260613.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260613.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260613.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260613.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260613.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260613.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260620.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260613.1': + '@typescript/native-preview@7.0.0-dev.20260620.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260613.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260613.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260613.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260613.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260613.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260613.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260613.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260620.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260620.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260620.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260620.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260620.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260620.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260620.1 '@ungap/structured-clone@1.3.0': {} @@ -13224,13 +13405,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@unpic/react@1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@unpic/core': 1.0.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -13246,7 +13427,7 @@ snapshots: '@resvg/resvg-wasm': 2.4.0 satori: 0.16.0 - '@vitejs/devtools-kit@0.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3)': + '@vitejs/devtools-kit@0.3.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3)': dependencies: '@devframes/hub': 0.5.2(devframe@0.5.2(typescript@6.0.3)) birpc: 4.0.0 @@ -13256,7 +13437,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 tinyexec: 1.2.3 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -13264,12 +13445,12 @@ snapshots: - typescript - utf-8-validate - '@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 es-module-lexer: 2.1.0 @@ -13280,21 +13461,46 @@ snapshots: srvx: 0.11.15 strip-literal: 3.1.0 turbo-stream: 3.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: react-server-dom-webpack: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24)': + '@vitest/browser-playwright@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(playwright@1.61.0)(vitest@4.1.9)': + dependencies: + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + playwright: 1.61.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - '@vitest/utils': 4.1.8 + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -13302,10 +13508,10 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24)': + '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -13314,89 +13520,33 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) optionalDependencies: - '@vitest/browser': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/eslint-plugin@1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)(vitest@4.1.9)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - supports-color - - terser - - tsx - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - '@vitest/eslint-plugin@1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - supports-color - - terser - - tsx - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml '@vitest/expect@3.2.4': dependencies: @@ -13406,27 +13556,48 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': + '@vitest/expect@4.1.9': dependencies: - '@vitest/spy': 4.1.8 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.9': {} '@vitest/utils@3.2.4': dependencies: @@ -13434,20 +13605,20 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': dependencies: - '@oxc-project/runtime': 0.133.0 - '@oxc-project/types': 0.133.0 + '@oxc-project/runtime': 0.136.0 + '@oxc-project/types': 0.136.0 lightningcss: 1.32.0 postcss: 8.5.15 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -13455,70 +13626,28 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 - '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': + '@voidzero-dev/vite-plus-darwin-arm64@0.2.1': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.1.24': + '@voidzero-dev/vite-plus-darwin-x64@0.2.1': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.1': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1': optional: true - '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.2.3 - tinyglobby: 0.2.16 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - ws: 8.21.0 - optionalDependencies: - '@types/node': 25.9.3 - '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24) - happy-dom: 20.10.3 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - yaml - - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.1': optional: true '@volar/language-core@2.4.28': @@ -13537,7 +13666,7 @@ snapshots: '@vue/compiler-core@3.5.31': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@vue/shared': 3.5.31 entities: 7.0.1 estree-walker: 2.0.2 @@ -13752,7 +13881,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 buffer@5.7.1: dependencies: @@ -13841,6 +13970,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@4.1.1: dependencies: ansi-styles: 4.3.0 @@ -13885,7 +14016,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.26.0 + undici: 7.27.2 whatwg-mimetype: 4.0.0 chokidar@5.0.0: @@ -13951,14 +14082,14 @@ snapshots: - '@types/react' - '@types/react-dom' - code-inspector-plugin@1.6.0(supports-color@10.2.2): + code-inspector-plugin@1.6.1(supports-color@10.2.2): dependencies: - '@code-inspector/core': 1.6.0(supports-color@10.2.2) - '@code-inspector/esbuild': 1.6.0 - '@code-inspector/mako': 1.6.0 - '@code-inspector/turbopack': 1.6.0 - '@code-inspector/vite': 1.6.0 - '@code-inspector/webpack': 1.6.0 + '@code-inspector/core': 1.6.1(supports-color@10.2.2) + '@code-inspector/esbuild': 1.6.1 + '@code-inspector/mako': 1.6.1 + '@code-inspector/turbopack': 1.6.1 + '@code-inspector/vite': 1.6.1 + '@code-inspector/webpack': 1.6.1 chalk: 4.1.1 transitivePeerDependencies: - supports-color @@ -14022,7 +14153,7 @@ snapshots: dependencies: layout-base: 2.0.1 - cron-parser@5.5.0: + cron-parser@5.6.0: dependencies: luxon: 3.7.2 @@ -14388,7 +14519,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.10: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -14708,7 +14839,7 @@ snapshots: dependencies: eslint: 10.5.0(jiti@2.7.0) - eslint-plugin-better-tailwindcss@4.6.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tailwindcss@4.3.1)(typescript@6.0.3): + eslint-plugin-better-tailwindcss@4.6.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tailwindcss@4.3.1)(typescript@6.0.3): dependencies: '@eslint/css-tree': 4.0.1 '@valibot/to-json-schema': 1.7.0(valibot@1.4.1(typescript@6.0.3)) @@ -14721,23 +14852,23 @@ snapshots: valibot: 1.4.1(typescript@6.0.3) optionalDependencies: eslint: 10.5.0(jiti@2.7.0) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - '@eslint/css' - typescript - eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: '@es-joy/jsdoccomment': 0.84.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)): + eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)): dependencies: '@es-joy/jsdoccomment': 0.84.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): @@ -15007,8 +15138,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 @@ -15022,8 +15153,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 10.5.0(jiti@2.7.0) typescript: 6.0.3 @@ -15037,8 +15168,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: @@ -15052,8 +15183,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -15065,8 +15196,8 @@ snapshots: '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -15080,8 +15211,8 @@ snapshots: '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 typescript: 6.0.3 @@ -15104,8 +15235,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: @@ -15119,8 +15250,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -15133,8 +15264,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) birecord: 0.1.1 eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) ts-pattern: 5.9.0 @@ -15150,8 +15281,8 @@ snapshots: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) birecord: 0.1.1 eslint: 10.5.0(jiti@2.7.0) ts-pattern: 5.9.0 @@ -15167,11 +15298,11 @@ snapshots: '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) string-ts: 2.3.1 @@ -15190,11 +15321,11 @@ snapshots: '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 10.5.0(jiti@2.7.0) string-ts: 2.3.1 @@ -15226,7 +15357,7 @@ snapshots: regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-sonarjs@4.0.3(eslint@10.5.0(jiti@2.7.0)): + eslint-plugin-sonarjs@4.1.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 @@ -15241,12 +15372,13 @@ snapshots: semver: 7.8.4 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 + yaml: 2.9.0 - eslint-plugin-storybook@10.4.4(eslint@10.5.0(jiti@2.7.0))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): + eslint-plugin-storybook@10.4.6(eslint@10.5.0(jiti@2.7.0))(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - typescript @@ -15313,19 +15445,19 @@ snapshots: semver: 7.8.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)): + eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) @@ -15337,7 +15469,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0))): dependencies: @@ -15351,7 +15483,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-yml@3.3.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: @@ -15538,6 +15670,8 @@ snapshots: expand-template@2.0.3: optional: true + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -15640,7 +15774,7 @@ snapshots: dependencies: fd-package-json: 2.0.0 - foxact@0.3.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + foxact@0.3.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: client-only: 0.0.1 event-target-bus: 1.0.0 @@ -15773,9 +15907,9 @@ snapshots: hachure-fill@0.5.2: {} - happy-dom@20.10.3: + happy-dom@20.10.6: dependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -15961,7 +16095,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.12.25: {} + hono@4.12.26: {} hosted-git-info@9.0.2: dependencies: @@ -16307,19 +16441,19 @@ snapshots: khroma@2.1.0: {} - knip@6.16.1: + knip@6.17.1: dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 - oxc-parser: 0.133.0 + oxc-parser: 0.135.0 oxc-resolver: 11.20.0 picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 - tinyglobby: 0.2.16 - unbash: 3.0.0 + tinyglobby: 0.2.17 + unbash: 4.0.1 yaml: 2.9.0 zod: 4.4.3 @@ -16453,7 +16587,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-crdt@1.13.2: {} + loro-crdt@1.13.5: {} loupe@3.2.1: {} @@ -16473,8 +16607,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: @@ -16718,7 +16852,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.10 + dompurify: 3.4.11 es-toolkit: 1.47.1 katex: 0.16.47 khroma: 2.1.0 @@ -16726,7 +16860,7 @@ snapshots: roughjs: 4.6.6 stylis: 4.3.6 ts-dedent: 2.2.0 - uuid: 14.0.0 + uuid: 14.0.1 micromark-core-commonmark@2.0.3: dependencies: @@ -17109,7 +17243,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -17128,7 +17262,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.9 '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 - '@playwright/test': 1.60.0 + '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -17164,12 +17298,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + nuqs@2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.7 optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) object-assign@4.1.1: {} @@ -17316,30 +17450,30 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.132.0 '@oxc-parser/binding-win32-x64-msvc': 0.132.0 - oxc-parser@0.133.0: + oxc-parser@0.135.0: dependencies: - '@oxc-project/types': 0.133.0 + '@oxc-project/types': 0.135.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.133.0 - '@oxc-parser/binding-android-arm64': 0.133.0 - '@oxc-parser/binding-darwin-arm64': 0.133.0 - '@oxc-parser/binding-darwin-x64': 0.133.0 - '@oxc-parser/binding-freebsd-x64': 0.133.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.133.0 - '@oxc-parser/binding-linux-arm64-musl': 0.133.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.133.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-musl': 0.133.0 - '@oxc-parser/binding-openharmony-arm64': 0.133.0 - '@oxc-parser/binding-wasm32-wasi': 0.133.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.133.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.133.0 - '@oxc-parser/binding-win32-x64-msvc': 0.133.0 + '@oxc-parser/binding-android-arm-eabi': 0.135.0 + '@oxc-parser/binding-android-arm64': 0.135.0 + '@oxc-parser/binding-darwin-arm64': 0.135.0 + '@oxc-parser/binding-darwin-x64': 0.135.0 + '@oxc-parser/binding-freebsd-x64': 0.135.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.135.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.135.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.135.0 + '@oxc-parser/binding-linux-arm64-musl': 0.135.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.135.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.135.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.135.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.135.0 + '@oxc-parser/binding-linux-x64-gnu': 0.135.0 + '@oxc-parser/binding-linux-x64-musl': 0.135.0 + '@oxc-parser/binding-openharmony-arm64': 0.135.0 + '@oxc-parser/binding-wasm32-wasi': 0.135.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.135.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.135.0 + '@oxc-parser/binding-win32-x64-msvc': 0.135.0 oxc-resolver@11.20.0: optionalDependencies: @@ -17363,55 +17497,30 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 - oxfmt@0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): + oxfmt@0.55.0(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.52.0 - '@oxfmt/binding-android-arm64': 0.52.0 - '@oxfmt/binding-darwin-arm64': 0.52.0 - '@oxfmt/binding-darwin-x64': 0.52.0 - '@oxfmt/binding-freebsd-x64': 0.52.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 - '@oxfmt/binding-linux-arm64-gnu': 0.52.0 - '@oxfmt/binding-linux-arm64-musl': 0.52.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-musl': 0.52.0 - '@oxfmt/binding-linux-s390x-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-musl': 0.52.0 - '@oxfmt/binding-openharmony-arm64': 0.52.0 - '@oxfmt/binding-win32-arm64-msvc': 0.52.0 - '@oxfmt/binding-win32-ia32-msvc': 0.52.0 - '@oxfmt/binding-win32-x64-msvc': 0.52.0 - vite-plus: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - - oxfmt@0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.52.0 - '@oxfmt/binding-android-arm64': 0.52.0 - '@oxfmt/binding-darwin-arm64': 0.52.0 - '@oxfmt/binding-darwin-x64': 0.52.0 - '@oxfmt/binding-freebsd-x64': 0.52.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 - '@oxfmt/binding-linux-arm64-gnu': 0.52.0 - '@oxfmt/binding-linux-arm64-musl': 0.52.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-musl': 0.52.0 - '@oxfmt/binding-linux-s390x-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-musl': 0.52.0 - '@oxfmt/binding-openharmony-arm64': 0.52.0 - '@oxfmt/binding-win32-arm64-msvc': 0.52.0 - '@oxfmt/binding-win32-ia32-msvc': 0.52.0 - '@oxfmt/binding-win32-x64-msvc': 0.52.0 - vite-plus: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + '@oxfmt/binding-android-arm-eabi': 0.55.0 + '@oxfmt/binding-android-arm64': 0.55.0 + '@oxfmt/binding-darwin-arm64': 0.55.0 + '@oxfmt/binding-darwin-x64': 0.55.0 + '@oxfmt/binding-freebsd-x64': 0.55.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.55.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.55.0 + '@oxfmt/binding-linux-arm64-gnu': 0.55.0 + '@oxfmt/binding-linux-arm64-musl': 0.55.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-musl': 0.55.0 + '@oxfmt/binding-linux-s390x-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-musl': 0.55.0 + '@oxfmt/binding-openharmony-arm64': 0.55.0 + '@oxfmt/binding-win32-arm64-msvc': 0.55.0 + '@oxfmt/binding-win32-ia32-msvc': 0.55.0 + '@oxfmt/binding-win32-x64-msvc': 0.55.0 + vite-plus: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) oxlint-tsgolint@0.23.0: optionalDependencies: @@ -17422,53 +17531,29 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.23.0 '@oxlint-tsgolint/win32-x64': 0.23.0 - oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): + oxlint@1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.67.0 - '@oxlint/binding-android-arm64': 1.67.0 - '@oxlint/binding-darwin-arm64': 1.67.0 - '@oxlint/binding-darwin-x64': 1.67.0 - '@oxlint/binding-freebsd-x64': 1.67.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 - '@oxlint/binding-linux-arm-musleabihf': 1.67.0 - '@oxlint/binding-linux-arm64-gnu': 1.67.0 - '@oxlint/binding-linux-arm64-musl': 1.67.0 - '@oxlint/binding-linux-ppc64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-musl': 1.67.0 - '@oxlint/binding-linux-s390x-gnu': 1.67.0 - '@oxlint/binding-linux-x64-gnu': 1.67.0 - '@oxlint/binding-linux-x64-musl': 1.67.0 - '@oxlint/binding-openharmony-arm64': 1.67.0 - '@oxlint/binding-win32-arm64-msvc': 1.67.0 - '@oxlint/binding-win32-ia32-msvc': 1.67.0 - '@oxlint/binding-win32-x64-msvc': 1.67.0 + '@oxlint/binding-android-arm-eabi': 1.70.0 + '@oxlint/binding-android-arm64': 1.70.0 + '@oxlint/binding-darwin-arm64': 1.70.0 + '@oxlint/binding-darwin-x64': 1.70.0 + '@oxlint/binding-freebsd-x64': 1.70.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.70.0 + '@oxlint/binding-linux-arm-musleabihf': 1.70.0 + '@oxlint/binding-linux-arm64-gnu': 1.70.0 + '@oxlint/binding-linux-arm64-musl': 1.70.0 + '@oxlint/binding-linux-ppc64-gnu': 1.70.0 + '@oxlint/binding-linux-riscv64-gnu': 1.70.0 + '@oxlint/binding-linux-riscv64-musl': 1.70.0 + '@oxlint/binding-linux-s390x-gnu': 1.70.0 + '@oxlint/binding-linux-x64-gnu': 1.70.0 + '@oxlint/binding-linux-x64-musl': 1.70.0 + '@oxlint/binding-openharmony-arm64': 1.70.0 + '@oxlint/binding-win32-arm64-msvc': 1.70.0 + '@oxlint/binding-win32-ia32-msvc': 1.70.0 + '@oxlint/binding-win32-x64-msvc': 1.70.0 oxlint-tsgolint: 0.23.0 - vite-plus: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - - oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.67.0 - '@oxlint/binding-android-arm64': 1.67.0 - '@oxlint/binding-darwin-arm64': 1.67.0 - '@oxlint/binding-darwin-x64': 1.67.0 - '@oxlint/binding-freebsd-x64': 1.67.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 - '@oxlint/binding-linux-arm-musleabihf': 1.67.0 - '@oxlint/binding-linux-arm64-gnu': 1.67.0 - '@oxlint/binding-linux-arm64-musl': 1.67.0 - '@oxlint/binding-linux-ppc64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-musl': 1.67.0 - '@oxlint/binding-linux-s390x-gnu': 1.67.0 - '@oxlint/binding-linux-x64-gnu': 1.67.0 - '@oxlint/binding-linux-x64-musl': 1.67.0 - '@oxlint/binding-openharmony-arm64': 1.67.0 - '@oxlint/binding-win32-arm64-msvc': 1.67.0 - '@oxlint/binding-win32-ia32-msvc': 1.67.0 - '@oxlint/binding-win32-x64-msvc': 1.67.0 - oxlint-tsgolint: 0.23.0 - vite-plus: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + vite-plus: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) p-limit@3.1.0: dependencies: @@ -17573,10 +17658,6 @@ snapshots: pinyin-pro@3.28.1: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -17589,11 +17670,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.60.0: {} + playwright-core@1.61.0: {} - playwright@1.60.0: + playwright@1.61.0: dependencies: - playwright-core: 1.60.0 + playwright-core: 1.61.0 optionalDependencies: fsevents: 2.3.2 @@ -17721,8 +17802,8 @@ snapshots: react-docgen@8.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -18245,37 +18326,37 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true - sharp@0.35.1: + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.4 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.1 - '@img/sharp-darwin-x64': 0.35.1 - '@img/sharp-freebsd-wasm32': 0.35.1 - '@img/sharp-libvips-darwin-arm64': 1.3.0 - '@img/sharp-libvips-darwin-x64': 1.3.0 - '@img/sharp-libvips-linux-arm': 1.3.0 - '@img/sharp-libvips-linux-arm64': 1.3.0 - '@img/sharp-libvips-linux-ppc64': 1.3.0 - '@img/sharp-libvips-linux-riscv64': 1.3.0 - '@img/sharp-libvips-linux-s390x': 1.3.0 - '@img/sharp-libvips-linux-x64': 1.3.0 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 - '@img/sharp-libvips-linuxmusl-x64': 1.3.0 - '@img/sharp-linux-arm': 0.35.1 - '@img/sharp-linux-arm64': 0.35.1 - '@img/sharp-linux-ppc64': 0.35.1 - '@img/sharp-linux-riscv64': 0.35.1 - '@img/sharp-linux-s390x': 0.35.1 - '@img/sharp-linux-x64': 0.35.1 - '@img/sharp-linuxmusl-arm64': 0.35.1 - '@img/sharp-linuxmusl-x64': 0.35.1 - '@img/sharp-webcontainers-wasm32': 0.35.1 - '@img/sharp-win32-arm64': 0.35.1 - '@img/sharp-win32-ia32': 0.35.1 - '@img/sharp-win32-x64': 0.35.1 + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 shebang-command@2.0.0: dependencies: @@ -18296,6 +18377,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -18376,6 +18459,8 @@ snapshots: srvx@0.11.15: {} + stackback@0.0.2: {} + stackframe@1.3.4: {} state-local@1.0.7: {} @@ -18391,7 +18476,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): + storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -18410,7 +18495,7 @@ snapshots: ws: 8.21.0 optionalDependencies: '@types/react': 19.2.17 - vite-plus: 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + vite-plus: 0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) transitivePeerDependencies: - '@testing-library/dom' - bufferutil @@ -18606,6 +18691,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} tinyrainbow@2.0.0: {} @@ -18614,11 +18704,11 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.4.2: {} + tldts-core@7.4.3: {} - tldts@7.4.2: + tldts@7.4.3: dependencies: - tldts-core: 7.4.2 + tldts-core: 7.4.3 to-regex-range@5.0.1: dependencies: @@ -18740,7 +18830,7 @@ snapshots: uglify-js@3.19.3: {} - unbash@3.0.0: {} + unbash@4.0.1: {} unbox-primitive@1.1.0: dependencies: @@ -18751,10 +18841,10 @@ snapshots: undici-types@7.24.6: {} - undici@7.26.0: {} - undici@7.27.2: {} + undici@7.28.0: {} + unicode-trie@2.0.0: dependencies: pako: 0.2.9 @@ -18890,7 +18980,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.0: {} + uuid@14.0.1: {} valibot@1.4.1(typescript@6.0.3): optionalDependencies: @@ -18916,23 +19006,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3): + vinext@0.1.6(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@unpic/react': 1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@unpic/react': 1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@vercel/og': 0.8.6 - '@vitejs/plugin-react': 6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@vitejs/plugin-react': 6.0.2(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) image-size: 2.0.2 ipaddr.js: 2.4.0 magic-string: 0.30.21 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plugin-commonjs: 0.10.4 - vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) + vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) web-vitals: 4.2.4 optionalDependencies: '@mdx-js/rollup': 3.1.1 - '@vitejs/plugin-rsc': 0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@vitejs/plugin-rsc': 0.5.27(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react-server-dom-webpack: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) transitivePeerDependencies: - next @@ -18952,9 +19042,9 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.21 - vite-plugin-inspect@12.0.0-beta.3(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3): + vite-plugin-inspect@12.0.0-beta.3(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3): dependencies: - '@vitejs/devtools-kit': 0.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) + '@vitejs/devtools-kit': 0.3.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) ansis: 4.3.0 error-stack-parser-es: 1.0.5 obug: 2.1.1 @@ -18963,7 +19053,7 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -18971,39 +19061,49 @@ snapshots: - typescript - utf-8-validate - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript - vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0): + vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0): dependencies: - '@oxc-project/types': 0.133.0 - '@oxlint/plugins': 1.61.0 - '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - '@voidzero-dev/vite-plus-test': 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - oxfmt: 0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@oxc-project/types': 0.136.0 + '@oxlint/plugins': 1.68.0 + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + '@voidzero-dev/vite-plus-core': 0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + oxfmt: 0.55.0(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) oxlint-tsgolint: 0.23.0 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.24 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.24 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.24 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.24 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.24 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.24 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.24 + '@vitest/browser-playwright': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(playwright@1.61.0)(vitest@4.1.9) + '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1 + '@voidzero-dev/vite-plus-darwin-x64': 0.2.1 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.1 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.1 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.1 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.1 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.1 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.1 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -19021,6 +19121,7 @@ snapshots: - jiti - jsdom - less + - msw - publint - sass - sass-embedded @@ -19036,95 +19137,76 @@ snapshots: - vite - yaml - vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0): - dependencies: - '@oxc-project/types': 0.133.0 - '@oxlint/plugins': 1.61.0 - '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - '@voidzero-dev/vite-plus-test': 0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) - oxfmt: 0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - oxlint-tsgolint: 0.23.0 - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.24 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.24 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.24 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.24 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.24 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.24 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.24 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): + vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): + vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: - supports-color - typescript - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.24): + vitest-canvas-mock@1.1.4(vitest@4.1.9): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6) + + vitest@4.1.9(@types/node@25.9.4)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.2.3 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.4 + '@vitest/browser-playwright': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(playwright@1.61.0)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + happy-dom: 20.10.6 + transitivePeerDependencies: + - msw void-elements@3.1.0: {} @@ -19219,6 +19301,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@9.0.2: @@ -19332,7 +19419,7 @@ time: '@formatjs/intl-localematcher@0.8.10': '2026-06-04T15:24:22.451Z' '@heroicons/react@2.2.0': '2024-11-18T15:33:27.317Z' '@hey-api/openapi-ts@0.98.2': '2026-06-08T05:37:17.524Z' - '@hono/node-server@2.0.4': '2026-05-24T08:20:41.156Z' + '@hono/node-server@2.0.5': '2026-06-15T12:55:56.300Z' '@iconify-json/heroicons@1.2.3': '2025-09-20T05:33:02.364Z' '@iconify-json/ri@1.2.10': '2026-02-10T08:41:46.666Z' '@lexical/link@0.45.0': '2026-05-28T20:34:31.715Z' @@ -19352,19 +19439,19 @@ time: '@orpc/contract@1.14.6': '2026-06-12T04:16:50.143Z' '@orpc/openapi-client@1.14.6': '2026-06-12T04:17:49.271Z' '@orpc/tanstack-query@1.14.6': '2026-06-12T04:17:13.682Z' - '@playwright/test@1.60.0': '2026-05-11T19:09:45.394Z' + '@playwright/test@1.61.0': '2026-06-15T10:06:35.237Z' '@remixicon/react@4.9.0': '2026-01-29T10:53:18.993Z' '@rgrove/parse-xml@4.2.0': '2024-10-25T03:58:22.145Z' - '@sentry/react@10.57.0': '2026-06-09T09:44:56.173Z' - '@storybook/addon-a11y@10.4.4': '2026-06-11T11:47:24.917Z' - '@storybook/addon-docs@10.4.4': '2026-06-11T11:47:25.111Z' - '@storybook/addon-links@10.4.4': '2026-06-11T11:47:29.814Z' - '@storybook/addon-onboarding@10.4.4': '2026-06-11T11:47:29.674Z' - '@storybook/addon-themes@10.4.4': '2026-06-11T11:47:34.054Z' - '@storybook/addon-vitest@10.4.4': '2026-06-11T11:47:37.457Z' - '@storybook/nextjs-vite@10.4.4': '2026-06-11T11:47:57.869Z' - '@storybook/react-vite@10.4.4': '2026-06-11T11:48:05.555Z' - '@storybook/react@10.4.4': '2026-06-11T11:48:50.264Z' + '@sentry/react@10.59.0': '2026-06-19T12:52:07.460Z' + '@storybook/addon-a11y@10.4.6': '2026-06-16T11:41:54.974Z' + '@storybook/addon-docs@10.4.6': '2026-06-16T11:41:55.049Z' + '@storybook/addon-links@10.4.6': '2026-06-16T11:42:01.532Z' + '@storybook/addon-onboarding@10.4.6': '2026-06-16T11:42:01.175Z' + '@storybook/addon-themes@10.4.6': '2026-06-16T11:42:06.465Z' + '@storybook/addon-vitest@10.4.6': '2026-06-16T11:42:09.951Z' + '@storybook/nextjs-vite@10.4.6': '2026-06-16T11:42:31.117Z' + '@storybook/react-vite@10.4.6': '2026-06-16T11:42:40.527Z' + '@storybook/react@10.4.6': '2026-06-16T11:43:30.413Z' '@streamdown/math@1.0.2': '2026-02-09T17:31:31.085Z' '@svgdotjs/svg.js@3.2.5': '2025-09-15T16:22:12.771Z' '@t3-oss/env-nextjs@0.13.11': '2026-03-22T19:16:09.026Z' @@ -19372,36 +19459,37 @@ time: '@tailwindcss/typography@0.5.20': '2026-06-08T10:34:41.124Z' '@tailwindcss/vite@4.3.1': '2026-06-12T17:59:45.667Z' '@tanstack/eslint-plugin-query@5.101.0': '2026-06-02T19:24:31.866Z' + '@tanstack/form-core@1.33.0': '2026-05-28T17:05:43.320Z' '@tanstack/query-core@5.101.0': '2026-06-02T19:24:32.202Z' '@tanstack/react-form@1.33.0': '2026-05-28T17:05:42.660Z' '@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z' '@tanstack/react-query@5.101.0': '2026-06-02T19:24:39.383Z' - '@tanstack/react-virtual@3.14.2': '2026-06-02T07:27:48.537Z' + '@tanstack/react-virtual@3.14.3': '2026-06-15T19:53:08.471Z' '@testing-library/dom@10.4.1': '2025-07-27T13:23:37.151Z' '@testing-library/jest-dom@6.9.1': '2025-10-01T20:04:22.720Z' '@testing-library/react@16.3.2': '2026-01-19T10:59:08.185Z' '@testing-library/user-event@14.6.1': '2025-01-21T17:35:55.574Z' - '@tsslint/cli@3.1.3': '2026-05-16T14:17:58.782Z' - '@tsslint/compat-eslint@3.1.3': '2026-05-16T14:17:54.194Z' - '@tsslint/config@3.1.3': '2026-05-16T14:17:56.687Z' + '@tsslint/cli@3.1.4': '2026-06-16T18:21:29.075Z' + '@tsslint/compat-eslint@3.1.4': '2026-06-16T18:21:23.786Z' + '@tsslint/config@3.1.4': '2026-06-16T18:21:26.545Z' '@types/js-cookie@3.0.6': '2023-11-07T08:41:16.889Z' '@types/js-yaml@4.0.9': '2023-11-07T20:20:13.264Z' '@types/lockfile@1.0.4': '2023-11-07T20:23:21.070Z' '@types/negotiator@0.6.4': '2025-06-07T02:18:17.532Z' - '@types/node@25.9.3': '2026-06-10T22:15:10.607Z' + '@types/node@25.9.4': '2026-06-19T07:15:05.196Z' '@types/qs@6.15.1': '2026-05-06T23:46:01.024Z' '@types/react-dom@19.2.3': '2025-11-12T04:37:39.524Z' '@types/react@19.2.17': '2026-06-05T20:10:24.692Z' '@types/sortablejs@1.15.9': '2025-10-24T04:31:45.132Z' - '@typescript-eslint/eslint-plugin@8.61.0': '2026-06-08T18:01:21.196Z' - '@typescript-eslint/parser@8.61.0': '2026-06-08T18:00:59.869Z' - '@typescript/native-preview@7.0.0-dev.20260613.1': '2026-06-13T08:01:38.753Z' + '@typescript-eslint/eslint-plugin@8.61.1': '2026-06-15T18:31:50.659Z' + '@typescript-eslint/parser@8.61.1': '2026-06-15T18:31:27.695Z' + '@typescript/native-preview@7.0.0-dev.20260620.1': '2026-06-20T08:01:54.980Z' '@vitejs/plugin-react@6.0.2': '2026-05-14T20:03:24.044Z' '@vitejs/plugin-rsc@0.5.27': '2026-06-01T09:07:55.389Z' - '@vitest/browser@4.1.8': '2026-06-01T08:14:46.126Z' - '@vitest/coverage-v8@4.1.8': '2026-06-01T08:15:09.012Z' - '@voidzero-dev/vite-plus-core@0.1.24': '2026-06-01T13:04:55.392Z' - '@voidzero-dev/vite-plus-test@0.1.24': '2026-06-01T13:05:02.584Z' + '@vitest/browser-playwright@4.1.9': '2026-06-15T07:21:50.860Z' + '@vitest/browser@4.1.9': '2026-06-15T07:21:58.373Z' + '@vitest/coverage-v8@4.1.9': '2026-06-15T07:21:14.145Z' + '@voidzero-dev/vite-plus-core@0.2.1': '2026-06-18T05:33:26.237Z' abcjs@6.6.3: '2026-04-24T17:38:01.079Z' agentation@3.0.2: '2026-03-25T16:24:19.682Z' ahooks@3.9.7: '2026-03-23T15:49:13.605Z' @@ -19411,13 +19499,13 @@ time: cli-table3@0.6.5: '2024-05-12T16:36:50.079Z' clsx@2.1.1: '2024-04-23T05:26:04.645Z' cmdk@1.1.1: '2025-03-14T19:21:16.194Z' - code-inspector-plugin@1.6.0: '2026-06-12T11:45:35.134Z' + code-inspector-plugin@1.6.1: '2026-06-15T01:14:26.288Z' concurrently@10.0.3: '2026-06-02T04:31:54.180Z' copy-to-clipboard@4.0.2: '2026-04-24T22:15:18.933Z' - cron-parser@5.5.0: '2026-01-16T13:14:50.225Z' + cron-parser@5.6.0: '2026-06-20T18:24:08.824Z' dayjs@1.11.21: '2026-05-26T05:46:12.427Z' decimal.js@10.6.0: '2025-07-06T22:50:38.844Z' - dompurify@3.4.10: '2026-06-12T12:49:46.101Z' + dompurify@3.4.11: '2026-06-17T10:33:28.065Z' echarts-for-react@3.0.6: '2026-01-21T04:38:21.243Z' echarts@6.1.0: '2026-05-19T17:52:11.076Z' elkjs@0.11.1: '2026-03-03T12:21:48.463Z' @@ -19433,16 +19521,16 @@ time: eslint-plugin-markdown-preferences@0.41.1: '2026-04-09T23:28:41.552Z' eslint-plugin-no-barrel-files@1.3.1: '2026-04-12T18:28:18.653Z' eslint-plugin-react-refresh@0.5.3: '2026-06-14T12:46:34.395Z' - eslint-plugin-sonarjs@4.0.3: '2026-04-16T08:09:42.856Z' - eslint-plugin-storybook@10.4.4: '2026-06-11T11:48:35.390Z' + eslint-plugin-sonarjs@4.1.0: '2026-06-18T17:14:11.087Z' + eslint-plugin-storybook@10.4.6: '2026-06-16T11:43:14.113Z' eslint@10.5.0: '2026-06-12T17:54:40.577Z' eventsource-parser@3.1.0: '2026-05-27T20:55:51.466Z' fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z' - foxact@0.3.5: '2026-06-11T15:55:09.889Z' + foxact@0.3.7: '2026-06-17T18:34:56.297Z' fuse.js@7.4.2: '2026-06-05T22:22:52.388Z' - happy-dom@20.10.3: '2026-06-12T22:45:20.950Z' + happy-dom@20.10.6: '2026-06-17T23:41:40.697Z' hast-util-to-jsx-runtime@2.3.6: '2025-03-05T11:30:29.166Z' - hono@4.12.25: '2026-06-09T03:28:50.819Z' + hono@4.12.26: '2026-06-18T02:18:42.144Z' html-entities@2.6.0: '2025-03-30T15:40:10.885Z' html-to-image@1.11.13: '2025-02-14T01:43:48.709Z' i18next-resources-to-backend@1.2.1: '2024-04-10T19:22:23.117Z' @@ -19457,13 +19545,13 @@ time: js-yaml@4.2.0: '2026-05-31T22:17:13.783Z' jsonschema@1.5.0: '2025-01-07T15:09:11.287Z' katex@0.17.0: '2026-05-22T08:06:26.967Z' - knip@6.16.1: '2026-06-06T17:52:39.499Z' + knip@6.17.1: '2026-06-16T06:18:07.787Z' ky@2.0.2: '2026-04-21T08:58:46.923Z' lamejs@1.2.1: '2021-12-02T15:44:40.036Z' lexical-code-no-prism@0.41.0: '2026-03-08T16:50:40.266Z' lexical@0.45.0: '2026-05-28T20:33:56.686Z' lockfile@1.0.4: '2018-04-17T00:36:12.565Z' - loro-crdt@1.13.2: '2026-06-12T05:06:09.528Z' + loro-crdt@1.13.5: '2026-06-21T00:28:46.635Z' mermaid@11.15.0: '2026-05-11T11:15:09.824Z' mime@4.1.0: '2025-09-12T17:53:01.376Z' mitt@3.0.1: '2023-07-04T17:31:47.638Z' @@ -19476,7 +19564,7 @@ time: ora@9.4.0: '2026-04-22T06:11:17.972Z' picocolors@1.1.1: '2024-10-16T18:20:03.921Z' pinyin-pro@3.28.1: '2026-04-10T09:18:57.903Z' - playwright@1.60.0: '2026-05-11T19:09:33.114Z' + playwright@1.61.0: '2026-06-15T10:06:22.269Z' postcss@8.5.15: '2026-05-19T09:51:29.843Z' qrcode.react@4.2.0: '2024-12-11T17:22:40.569Z' qs@6.15.2: '2026-05-16T23:19:19.539Z' @@ -19495,29 +19583,30 @@ time: remark-directive@4.0.0: '2025-02-27T15:15:20.630Z' scheduler@0.27.0: '2025-10-01T21:39:15.208Z' server-only@0.0.1: '2022-09-03T01:07:26.139Z' - sharp@0.35.1: '2026-06-11T17:10:33.369Z' + sharp@0.35.2: '2026-06-19T13:47:27.073Z' shiki@4.2.0: '2026-06-03T01:35:47.302Z' socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z' sortablejs@1.15.7: '2026-02-11T22:42:31.720Z' std-semver@1.0.8: '2026-03-09T17:23:55.795Z' - storybook@10.4.4: '2026-06-11T11:47:45.294Z' + storybook@10.4.6: '2026-06-16T11:42:18.729Z' streamdown@2.5.0: '2026-03-17T17:35:05.216Z' string-ts@2.3.1: '2025-11-28T17:33:10.099Z' tailwind-merge@3.6.0: '2026-05-10T12:56:43.142Z' tailwindcss@4.3.1: '2026-06-12T17:59:19.225Z' - tldts@7.4.2: '2026-05-30T09:56:28.759Z' + tldts@7.4.3: '2026-06-15T14:51:15.009Z' tsx@4.22.4: '2026-05-31T12:22:19.330Z' typescript@6.0.3: '2026-04-16T23:38:27.905Z' uglify-js@3.19.3: '2024-08-29T13:49:01.316Z' - undici@7.27.2: '2026-06-06T08:21:50.946Z' + undici@7.28.0: '2026-06-15T15:51:12.886Z' unist-util-visit@5.1.0: '2026-01-22T19:02:58.977Z' use-context-selector@2.0.0: '2024-05-06T11:23:59.259Z' - uuid@14.0.0: '2026-04-19T15:15:42.302Z' - vinext@0.1.2: '2026-06-12T10:31:09.245Z' + uuid@14.0.1: '2026-06-20T11:56:02.499Z' + vinext@0.1.6: '2026-06-19T15:54:13.598Z' vite-plugin-inspect@12.0.0-beta.3: '2026-05-29T06:16:55.694Z' - vite-plus@0.1.24: '2026-06-01T13:05:08.610Z' + vite-plus@0.2.1: '2026-06-18T05:33:32.399Z' vitest-browser-react@2.2.0: '2026-04-05T06:56:34.635Z' vitest-canvas-mock@1.1.4: '2026-03-24T14:42:39.285Z' + vitest@4.1.9: '2026-06-15T07:23:00.326Z' zod@4.4.3: '2026-05-04T07:06:40.819Z' zundo@2.3.0: '2024-11-17T16:35:11.372Z' zustand@5.0.14: '2026-05-28T10:17:58.249Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0971ce8a1f5..39c72faa330 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,14 +39,14 @@ overrides: postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4 postcss@<8.5.10: ^8.5.10 - rollup@>=4.0.0 <4.59.0: 4.61.1 + rollup@>=4.0.0 <4.59.0: 4.62.2 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 solid-js: 1.9.13 string-width: ~8.2.1 tar@<=7.5.15: ^7.5.16 - vite: npm:@voidzero-dev/vite-plus-core@0.1.24 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 + vite: npm:@voidzero-dev/vite-plus-core@0.2.1 + vitest: 4.1.9 ws@>=8.0.0 <8.20.1: ^8.21.0 yaml@>=2.0.0 <2.8.3: 2.9.0 yauzl@<3.2.1: 3.2.1 @@ -65,7 +65,7 @@ catalog: '@formatjs/intl-localematcher': 0.8.10 '@heroicons/react': 2.2.0 '@hey-api/openapi-ts': 0.98.2 - '@hono/node-server': 2.0.4 + '@hono/node-server': 2.0.5 '@iconify-json/heroicons': 1.2.3 '@iconify-json/ri': 1.2.10 '@lexical/code': 0.45.0 @@ -86,19 +86,19 @@ catalog: '@orpc/contract': 1.14.6 '@orpc/openapi-client': 1.14.6 '@orpc/tanstack-query': 1.14.6 - '@playwright/test': 1.60.0 + '@playwright/test': 1.61.0 '@remixicon/react': 4.9.0 '@rgrove/parse-xml': 4.2.0 - '@sentry/react': 10.57.0 - '@storybook/addon-a11y': 10.4.4 - '@storybook/addon-docs': 10.4.4 - '@storybook/addon-links': 10.4.4 - '@storybook/addon-onboarding': 10.4.4 - '@storybook/addon-themes': 10.4.4 - '@storybook/addon-vitest': 10.4.4 - '@storybook/nextjs-vite': 10.4.4 - '@storybook/react': 10.4.4 - '@storybook/react-vite': 10.4.4 + '@sentry/react': 10.59.0 + '@storybook/addon-a11y': 10.4.6 + '@storybook/addon-docs': 10.4.6 + '@storybook/addon-links': 10.4.6 + '@storybook/addon-onboarding': 10.4.6 + '@storybook/addon-themes': 10.4.6 + '@storybook/addon-vitest': 10.4.6 + '@storybook/nextjs-vite': 10.4.6 + '@storybook/react': 10.4.6 + '@storybook/react-vite': 10.4.6 '@streamdown/math': 1.0.2 '@svgdotjs/svg.js': 3.2.5 '@t3-oss/env-nextjs': 0.13.11 @@ -106,34 +106,36 @@ catalog: '@tailwindcss/typography': 0.5.20 '@tailwindcss/vite': 4.3.1 '@tanstack/eslint-plugin-query': 5.101.0 + '@tanstack/form-core': 1.33.0 '@tanstack/query-core': 5.101.0 '@tanstack/react-form': 1.33.0 '@tanstack/react-hotkeys': 0.10.0 '@tanstack/react-query': 5.101.0 - '@tanstack/react-virtual': 3.14.2 + '@tanstack/react-virtual': 3.14.3 '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 16.3.2 '@testing-library/user-event': 14.6.1 - '@tsslint/cli': 3.1.3 - '@tsslint/compat-eslint': 3.1.3 - '@tsslint/config': 3.1.3 + '@tsslint/cli': 3.1.4 + '@tsslint/compat-eslint': 3.1.4 + '@tsslint/config': 3.1.4 '@types/js-cookie': 3.0.6 '@types/js-yaml': 4.0.9 '@types/lockfile': 1.0.4 '@types/negotiator': 0.6.4 - '@types/node': 25.9.3 + '@types/node': 25.9.4 '@types/qs': 6.15.1 '@types/react': 19.2.17 '@types/react-dom': 19.2.3 '@types/sortablejs': 1.15.9 - '@typescript-eslint/eslint-plugin': 8.61.0 - '@typescript-eslint/parser': 8.61.0 - '@typescript/native-preview': 7.0.0-dev.20260613.1 + '@typescript-eslint/eslint-plugin': 8.61.1 + '@typescript-eslint/parser': 8.61.1 + '@typescript/native-preview': 7.0.0-dev.20260620.1 '@vitejs/plugin-react': 6.0.2 '@vitejs/plugin-rsc': 0.5.27 - '@vitest/browser': 4.1.8 - '@vitest/coverage-v8': 4.1.8 + '@vitest/browser': 4.1.9 + '@vitest/browser-playwright': 4.1.9 + '@vitest/coverage-v8': 4.1.9 abcjs: 6.6.3 agentation: 3.0.2 ahooks: 3.9.7 @@ -143,13 +145,13 @@ catalog: cli-table3: 0.6.5 clsx: 2.1.1 cmdk: 1.1.1 - code-inspector-plugin: 1.6.0 + code-inspector-plugin: 1.6.1 concurrently: ^10.0.3 copy-to-clipboard: 4.0.2 - cron-parser: 5.5.0 + cron-parser: 5.6.0 dayjs: 1.11.21 decimal.js: 10.6.0 - dompurify: 3.4.10 + dompurify: 3.4.11 echarts: 6.1.0 echarts-for-react: 3.0.6 elkjs: 0.11.1 @@ -166,15 +168,15 @@ catalog: eslint-plugin-markdown-preferences: 0.41.1 eslint-plugin-no-barrel-files: 1.3.1 eslint-plugin-react-refresh: 0.5.3 - eslint-plugin-sonarjs: 4.0.3 - eslint-plugin-storybook: 10.4.4 + eslint-plugin-sonarjs: 4.1.0 + eslint-plugin-storybook: 10.4.6 eventsource-parser: 3.1.0 fast-deep-equal: 3.1.3 - foxact: 0.3.5 + foxact: 0.3.7 fuse.js: 7.4.2 - happy-dom: 20.10.3 + happy-dom: 20.10.6 hast-util-to-jsx-runtime: 2.3.6 - hono: 4.12.25 + hono: 4.12.26 html-entities: 2.6.0 html-to-image: 1.11.13 i18next: 26.3.1 @@ -189,12 +191,12 @@ catalog: js-yaml: 4.2.0 jsonschema: 1.5.0 katex: 0.17.0 - knip: 6.16.1 + knip: 6.17.1 ky: 2.0.2 lamejs: 1.2.1 lexical: 0.45.0 lockfile: 1.0.4 - loro-crdt: 1.13.2 + loro-crdt: 1.13.5 mermaid: 11.15.0 mime: 4.1.0 mitt: 3.0.1 @@ -207,7 +209,7 @@ catalog: ora: 9.4.0 picocolors: 1.1.1 pinyin-pro: 3.28.1 - playwright: 1.60.0 + playwright: 1.61.0 postcss: 8.5.15 qrcode.react: 4.2.0 qs: 6.15.2 @@ -226,29 +228,29 @@ catalog: remark-directive: 4.0.0 scheduler: 0.27.0 server-only: 0.0.1 - sharp: 0.35.1 + sharp: 0.35.2 shiki: 4.2.0 socket.io-client: 4.8.3 sortablejs: 1.15.7 std-semver: 1.0.8 - storybook: 10.4.4 + storybook: 10.4.6 streamdown: 2.5.0 string-ts: 2.3.1 tailwind-merge: 3.6.0 tailwindcss: 4.3.1 - tldts: 7.4.2 + tldts: 7.4.3 tsx: 4.22.4 typescript: 6.0.3 uglify-js: 3.19.3 - undici: 7.27.2 + undici: 7.28.0 unist-util-visit: 5.1.0 use-context-selector: 2.0.0 - uuid: 14.0.0 - vinext: 0.1.2 - vite: npm:@voidzero-dev/vite-plus-core@0.1.24 + uuid: 14.0.1 + vinext: 0.1.6 + vite: npm:@voidzero-dev/vite-plus-core@0.2.1 vite-plugin-inspect: 12.0.0-beta.3 - vite-plus: 0.1.24 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 + vite-plus: 0.2.1 + vitest: 4.1.9 vitest-browser-react: 2.2.0 vitest-canvas-mock: 1.1.4 zod: 4.4.3 diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx index bff6f5a29c5..62761743a10 100644 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -1,6 +1,7 @@ import type { DataSet } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import DatasetInfo from '@/app/components/app-sidebar/dataset-info' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index e99e7ac24c2..8cbfae8d693 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -49,7 +49,7 @@ vi.mock('@/context/app-context', () => ({ isCurrentWorkspaceManager: true, }), useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ - workspacePermissionKeys: ['tool.manage', 'credential.manage', 'credential.use'], + workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'], }), })) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index f9742f35e13..ec7aad05b74 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -1,5 +1,6 @@ import type { App } from '@/types/app' -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useStore } from '@/app/components/app/store' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' @@ -10,6 +11,13 @@ import AppDetailLayout from '../layout-main' const mockReplace = vi.fn() let mockPathname = '/app/app-1/workflow' let mockIsLoadingWorkspacePermissionKeys = false +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -57,6 +65,7 @@ describe('AppDetailLayout', () => { vi.clearAllMocks() mockPathname = '/app/app-1/workflow' mockIsLoadingWorkspacePermissionKeys = false + mockIsRbacEnabled = true mockUsePathname.mockImplementation(() => mockPathname) mockUseRouter.mockReturnValue({ back: vi.fn(), @@ -262,6 +271,24 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) + it('should redirect access config pages when RBAC is disabled', async () => { + mockIsRbacEnabled = false + mockPathname = '/app/app-1/access-config' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + it('should redirect annotation pages when edit access is missing', async () => { mockPathname = '/app/app-1/annotations' mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index dcbcba4116d..517c8819e91 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,6 +10,7 @@ import { useShallow } from 'zustand/react/shallow' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' @@ -36,7 +38,9 @@ const AppDetailLayout: FC = (props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, currentWorkspace, userProfile, workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const { appDetail, setAppDetail } = useStore(useShallow(state => ({ appDetail: state.appDetail, setAppDetail: state.setAppDetail, @@ -95,6 +99,7 @@ const AppDetailLayout: FC = (props) => { currentUserId: userProfile?.id, resourceMaintainer: routeAppDetail.maintainer, workspacePermissionKeys, + isRbacEnabled, }) const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') const isLogsPath = pathname.endsWith('logs') @@ -112,6 +117,7 @@ const AppDetailLayout: FC = (props) => { currentUserId: userProfile?.id, resourceMaintainer: routeAppDetail.maintainer, workspacePermissionKeys, + isRbacEnabled, })) return } @@ -125,7 +131,7 @@ const AppDetailLayout: FC = (props) => { if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) - }, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys]) + }, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys]) if (!appDetail) { return ( diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 23a9672a220..d669a675dac 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -1,10 +1,18 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { usePathname, useRouter } from '@/next/navigation' import { useDatasetDetail } from '@/service/knowledge/use-dataset' import { DatasetACLPermission } from '@/utils/permission' import DatasetDetailLayout from '../layout-main' const mockReplace = vi.fn() +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -42,6 +50,7 @@ const mockUseDatasetDetail = vi.mocked(useDatasetDetail) describe('DatasetDetailLayout', () => { beforeEach(() => { vi.clearAllMocks() + mockIsRbacEnabled = true mockUsePathname.mockReturnValue('/datasets/dataset-1/documents') mockUseRouter.mockReturnValue({ back: vi.fn(), @@ -292,5 +301,36 @@ describe('DatasetDetailLayout', () => { expect(screen.getByText('Access config content')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) + + it('should redirect from access config when RBAC is disabled', async () => { + // Arrange + mockIsRbacEnabled = false + mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') + mockUseDatasetDetail.mockReturnValue({ + data: { + id: 'dataset-1', + name: 'Dataset 1', + provider: 'vendor', + runtime_mode: 'general', + is_published: true, + permission_keys: [DatasetACLPermission.AccessConfig], + }, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType) + + // Act + render( + +
Access config content
+
, + ) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets/dataset-1/documents') + }) + expect(screen.queryByText('Access config content')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 3791009061d..a7df61c6c57 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -2,12 +2,14 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' import DatasetDetailContext from '@/context/dataset-detail' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { useDatasetDetail } from '@/service/knowledge/use-dataset' @@ -56,12 +58,14 @@ const DatasetDetailLayout: FC = (props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, userProfile, workspacePermissionKeys, } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId) const shouldRedirect = shouldRedirectToDatasetList(error) @@ -69,7 +73,8 @@ const DatasetDetailLayout: FC = (props) => { currentUserId: userProfile?.id, resourceMaintainer: datasetRes?.maintainer, workspacePermissionKeys, - }), [datasetRes?.maintainer, datasetRes?.permission_keys, userProfile?.id, workspacePermissionKeys]) + isRbacEnabled, + }), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys]) const isAccessConfigPath = pathname.endsWith('/access-config') const isHitTestingPath = pathname.endsWith('/hitTesting') const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 6dc9f5dfb19..8d83f6c7711 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AppACLPermission } from '@/utils/permission' import AppDetailSection from '../app-detail-section' import { useAppInfoActions } from '../app-info/use-app-info-actions' @@ -6,6 +7,13 @@ import { useAppInfoActions } from '../app-info/use-app-info-actions' let mockAppMode = 'chat' let mockPathname = '/app/app-1/logs' let mockAppPermissionKeys: string[] = [] +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ @@ -56,6 +64,7 @@ describe('AppDetailSection', () => { mockAppMode = 'chat' mockPathname = '/app/app-1/logs' mockAppPermissionKeys = [AppACLPermission.Monitor] + mockIsRbacEnabled = true }) // Rendering behavior for app detail navigation entries. @@ -203,6 +212,18 @@ describe('AppDetailSection', () => { expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() }) + it('should hide resource access navigation when RBAC is disabled', () => { + // Arrange + mockIsRbacEnabled = false + mockAppPermissionKeys = [AppACLPermission.AccessConfig] + + // Act + render() + + // Assert + expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + }) + it('should pass collapsed mode to app info and navigation links when collapsed', () => { // Act render() diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index 25763fb11ae..2b19ee5043e 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -1,11 +1,19 @@ import type { DataSet, RelatedAppResponse } from '@/models/datasets' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { DatasetACLPermission } from '@/utils/permission' import DatasetDetailSection from '../dataset-detail-section' let mockPathname = '/datasets/dataset-1/documents' let mockDataset: DataSet | undefined let mockRelatedApps: RelatedAppResponse | undefined +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, @@ -77,6 +85,7 @@ describe('DatasetDetailSection', () => { beforeEach(() => { vi.clearAllMocks() mockPathname = '/datasets/dataset-1/documents' + mockIsRbacEnabled = true mockDataset = createDataset() mockRelatedApps = { data: [], @@ -120,6 +129,17 @@ describe('DatasetDetailSection', () => { expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() }) + it('should hide resource access navigation when RBAC is disabled', () => { + mockIsRbacEnabled = false + mockDataset = createDataset({ + permission_keys: [DatasetACLPermission.AccessConfig], + }) + + render() + + expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + }) + it('should render hit testing navigation as a link when retrieval recall permission is granted', () => { mockDataset = createDataset({ permission_keys: [DatasetACLPermission.RetrievalRecall], diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 5df0a4ef573..15d963244cb 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -15,12 +15,14 @@ import { RiTerminalWindowFill, RiTerminalWindowLine, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { Fragment, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' import Annotations from '@/app/components/base/icons/src/vender/Annotations' import { useAppContext } from '@/context/app-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { usePathname } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' @@ -71,7 +73,9 @@ const AppDetailSection = ({ }: AppDetailSectionProps) => { const { t } = useTranslation() const pathname = usePathname() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { userProfile, workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const appDetail = useStore(state => state.appDetail) const appInfoActions = useAppInfoActions({ resetKey: appDetail?.id, @@ -88,6 +92,7 @@ const AppDetailSection = ({ currentUserId: userProfile?.id, resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, + isRbacEnabled, }) return [ @@ -143,7 +148,7 @@ const AppDetailSection = ({ : [] ), ] - }, [appDetail, t, userProfile?.id, workspacePermissionKeys]) + }, [appDetail, t, userProfile?.id, workspacePermissionKeys, isRbacEnabled]) if (!appDetail) return null diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 898b8c55a74..8f417ee7ce4 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -69,6 +69,10 @@ vi.mock('@/service/use-apps', () => ({ })) vi.mock('@tanstack/react-query', () => ({ + queryOptions: (options: TOptions) => options, + useSuspenseQuery: () => ({ + data: { rbac_enabled: true }, + }), useQueryClient: () => ({ setQueryData: mockSetQueryData, }), diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 89688a4882f..24931418733 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -3,12 +3,13 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' import { toast } from '@langgenius/dify-ui/toast' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps' import { appDetailQueryKeyPrefix, useInvalidateAppList } from '@/service/use-apps' @@ -58,6 +59,8 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) const invalidateAppList = useInvalidateAppList() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const [uiState, setUiState] = useState(() => createInitialUiState(resetKey)) const uiStateMatchesResetKey = uiState.resetKey === resetKey @@ -216,12 +219,12 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction toast(t('newApp.appCreated', { ns: 'app' }), { type: 'success' }) setNeedRefresh('1') onPlanInfoChanged() - getRedirection(newApp, replace) + getRedirection(newApp, replace, { isRbacEnabled }) } catch { toast(t('newApp.appCreateFailed', { ns: 'app' }), { type: 'error' }) } - }, [appDetail, closeModal, onPlanInfoChanged, replace, setNeedRefresh, t]) + }, [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, replace, setNeedRefresh, t]) const onExport = useCallback(async (include = false) => { if (!appDetail) diff --git a/web/app/components/app-sidebar/dataset-detail-section.tsx b/web/app/components/app-sidebar/dataset-detail-section.tsx index f337c6b15aa..4c57f9eb51a 100644 --- a/web/app/components/app-sidebar/dataset-detail-section.tsx +++ b/web/app/components/app-sidebar/dataset-detail-section.tsx @@ -12,6 +12,7 @@ import { RiLock2Fill, RiLock2Line, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' @@ -19,6 +20,7 @@ import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vend import ExtraInfo from '@/app/components/datasets/extra-info' import { useAppContext } from '@/context/app-context' import DatasetDetailContext from '@/context/dataset-detail' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { usePathname } from '@/next/navigation' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' import { getDatasetACLCapabilities } from '@/utils/permission' @@ -40,14 +42,17 @@ const DatasetDetailSection = ({ const { t } = useTranslation() const pathname = usePathname() const datasetId = getDatasetIdFromPathname(pathname) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { userProfile, workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const { data: datasetRes, refetch: mutateDatasetRes } = useDatasetDetail(datasetId ?? '') const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { enabled: !!datasetId && !!datasetRes }) const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, { currentUserId: userProfile?.id, resourceMaintainer: datasetRes?.maintainer, workspacePermissionKeys, - }), [datasetRes?.maintainer, datasetRes?.permission_keys, userProfile?.id, workspacePermissionKeys]) + isRbacEnabled, + }), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys]) const isButtonDisabledWithPipeline = useMemo(() => { if (!datasetRes) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index e1bfb008a71..6155445befb 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -1,7 +1,8 @@ import type { DataSet } from '@/models/datasets' -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { ChunkingMode, DatasetPermission, @@ -19,6 +20,13 @@ const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() const mockToast = vi.fn() +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -94,6 +102,13 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), })) +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), +})) + vi.mock('@/service/knowledge/use-dataset', () => ({ datasetDetailQueryKeyPrefix: ['dataset', 'detail'], useInvalidDatasetList: () => mockInvalidDatasetList, @@ -142,6 +157,7 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({ describe('Dropdown callback coverage', () => { beforeEach(() => { vi.clearAllMocks() + mockIsRbacEnabled = true mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' }) mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' }) mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index 63e05435c99..a12e7348675 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -1,7 +1,8 @@ import type { DataSet } from '@/models/datasets' -import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createEvent, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { ChunkingMode, DatasetPermission, @@ -23,6 +24,13 @@ const mockExportPipeline = vi.fn() const mockCheckIsUsedInApp = vi.fn() const mockDeleteDataset = vi.fn() const TestEditIcon = () => +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -107,6 +115,13 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), })) +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + }), +})) + vi.mock('@/service/knowledge/use-dataset', () => ({ datasetDetailQueryKeyPrefix: ['dataset', 'detail'], useInvalidDatasetList: () => mockInvalidDatasetList, @@ -162,6 +177,7 @@ const openMenu = async (user: ReturnType) => { describe('DatasetInfo', () => { beforeEach(() => { vi.clearAllMocks() + mockIsRbacEnabled = true mockDataset = createDataset() }) @@ -374,6 +390,7 @@ describe('Dropdown', () => { beforeEach(() => { vi.clearAllMocks() mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' }) + mockIsRbacEnabled = true mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' }) mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) mockDeleteDataset.mockResolvedValue({}) @@ -430,6 +447,24 @@ describe('Dropdown', () => { expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) + + it('should hide resource access option when RBAC is disabled', async () => { + const user = userEvent.setup() + // Arrange + mockIsRbacEnabled = false + mockDataset = createDataset({ + runtime_mode: 'general', + permission_keys: [DatasetACLPermission.AccessConfig, DatasetACLPermission.Delete], + }) + render() + + // Act + await openMenu(user) + + // Assert + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument() + }) }) // User interactions that trigger modals and exports. diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 6539e6e1715..51c433d5f0a 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -15,11 +15,13 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' import { checkIsUsedInApp, deleteDataset } from '@/service/datasets' import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset' @@ -69,11 +71,14 @@ const DropDown = ({ const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { currentUserId, resourceMaintainer: dataset?.maintainer, workspacePermissionKeys, - }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys]) + isRbacEnabled, + }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys]) const canShowOperations = datasetACLCapabilities.canEdit || datasetACLCapabilities.canImportExportDSL || datasetACLCapabilities.canAccessConfig diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx index 49e8c86825d..63909979344 100644 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useStore } from '@/app/components/app/store' import { useAppAccessRules, @@ -13,6 +14,14 @@ const mockAppContext = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) + const mockAppAccessRules = vi.hoisted(() => ({ items: [] as AccessRulesEditorProps['rules'], isLoading: false, @@ -75,6 +84,7 @@ describe('AppAccessConfigPage', () => { vi.clearAllMocks() mockAppContext.userProfile = { id: 'user-1' } mockAppContext.workspacePermissionKeys = [] + mockIsRbacEnabled = true mockAppAccessRules.items = [] mockAppAccessRules.isLoading = false mockAppUserAccessSettings.data = [] @@ -195,6 +205,16 @@ describe('AppAccessConfigPage', () => { expect(useAppUserAccessSettings).not.toHaveBeenCalled() }) + it('should not mount access config data hooks when RBAC is disabled', () => { + mockIsRbacEnabled = false + + render() + + expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() + expect(useAppAccessRules).not.toHaveBeenCalled() + expect(useAppUserAccessSettings).not.toHaveBeenCalled() + }) + it('should allow the app maintainer with app management workspace permission', () => { mockAppContext.userProfile = { id: 'account-1' } mockAppContext.workspacePermissionKeys = ['app.create_and_management'] diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx index 9704540bbd7..6efc718012c 100644 --- a/web/app/components/app/access-config/index.tsx +++ b/web/app/components/app/access-config/index.tsx @@ -2,12 +2,14 @@ import type { ResourceOpenScope } from '@/models/access-control' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AccessRulesEditor from '@/app/components/access-rules-editor' import { useStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getAccessControlTemplateLanguage } from '@/i18n-config/language' import { useAppAccessRules, @@ -103,13 +105,16 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP } const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => { + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { userProfile, workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const appDetail = useStore(state => state.appDetail) const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { currentUserId: userProfile?.id, resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, userProfile?.id, workspacePermissionKeys]) + isRbacEnabled, + }), [appDetail?.maintainer, appDetail?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys]) if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) return null diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index f5b800ee40b..0fe3770645b 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -30,6 +30,7 @@ const sectionProps = vi.hoisted(() => ({ actions: null as null | Record, })) const hotkeyMocks = vi.hoisted(() => ({ + hotkeys: [] as string[], handlers: [] as Array<(event: { preventDefault: () => void }) => void>, })) @@ -44,7 +45,8 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@tanstack/react-hotkeys', () => ({ - useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => { + useHotkey: (hotkey: string, handler: (event: { preventDefault: () => void }) => void) => { + hotkeyMocks.hotkeys.push(hotkey) hotkeyMocks.handlers.push(handler) }, })) @@ -190,6 +192,7 @@ vi.mock('../sections', () => ({ describe('AppPublisher', () => { beforeEach(() => { vi.clearAllMocks() + hotkeyMocks.hotkeys.length = 0 hotkeyMocks.handlers.length = 0 sectionProps.summary = null sectionProps.access = null @@ -240,6 +243,7 @@ describe('AppPublisher', () => { enabled: true, }) }) + expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P']) expect(mockRefetch).not.toHaveBeenCalled() }) @@ -477,6 +481,7 @@ describe('AppPublisher', () => { />, ) + expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P') hotkeyMocks.handlers[0]!({ preventDefault }) await waitFor(() => { diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index f3e65682a8f..e0713ecdd74 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,3 +1,4 @@ +import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' @@ -82,7 +83,8 @@ export type AppPublisherProps = { hasHumanInputNode?: boolean } -const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey +const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+') export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams @@ -290,7 +292,7 @@ export function AppPublisher({ } } - useHotkey('Mod+Shift+P', (e) => { + useHotkey(PUBLISH_HOTKEY, (e) => { e.preventDefault() if (publishDisabled || published) return diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 72e3ff2c19a..ada0afe17fa 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { AppModeEnum } from '@/types/app' import Apps from '../index' @@ -295,7 +296,7 @@ describe('Apps', () => { id: 'created-app-id', mode: AppModeEnum.CHAT, permission_keys: ['app.acl.view_layout'], - }, mockPush) + }, mockPush, { isRbacEnabled: false }) }) it('shows an error toast when importing the template fails', async () => { diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 04e759575b9..c97b8b5fcb5 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -5,6 +5,7 @@ import type { App } from '@/models/explore' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { RiRobot2Line } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useMemo, useState } from 'react' @@ -17,6 +18,7 @@ import Loading from '@/app/components/base/loading' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useAppContext } from '@/context/app-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { DSLImportMode } from '@/models/app' import { useRouter } from '@/next/navigation' import { importDSL } from '@/service/apps' @@ -45,7 +47,9 @@ const Apps = ({ onCreateFromBlank, }: AppsProps) => { const { t } = useTranslation() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const canCreateAppFromTemplate = hasPermission(workspacePermissionKeys, 'app.create_and_management') const { push } = useRouter() const invalidateAppList = useInvalidateAppList() @@ -144,7 +148,7 @@ const Apps = ({ setNeedRefresh('1') invalidateAppList() if (app.app_id) - getRedirection({ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, push) + getRedirection({ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, push, { isRbacEnabled }) } catch { toast.error(t('newApp.appCreateFailed', { ns: 'app' })) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 47c8fb5c46c..d583dadedda 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -1,7 +1,8 @@ import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' @@ -177,6 +178,7 @@ describe('CreateAppModal', () => { currentUserId: 'user-1', resourceMaintainer: 'user-1', workspacePermissionKeys: ['app.create_and_management'], + isRbacEnabled: false, }), ) }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index ce3f11f24c3..a53f07d5b12 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -9,6 +9,7 @@ import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,6 +21,7 @@ import Input from '@/app/components/base/input' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useTheme from '@/hooks/use-theme' import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' @@ -56,7 +58,9 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const { plan, enableBilling } = useProviderContext() const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { userProfile, workspacePermissionKeys } = useAppContext() + const isRbacEnabled = systemFeatures.rbac_enabled const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const invalidateAppList = useInvalidateAppList() @@ -100,13 +104,14 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: currentUserId: userProfile?.id, resourceMaintainer: app.maintainer, workspacePermissionKeys, + isRbacEnabled, }) } catch (error) { toast.error(error instanceof Error ? error.message : t('newApp.appCreateFailed', { ns: 'app' })) } isCreatingRef.current = false - }, [canCreateApp, name, t, appMode, appIcon, description, onSuccess, onClose, push, userProfile?.id, workspacePermissionKeys, setNeedRefresh, invalidateAppList]) + }, [canCreateApp, name, t, appMode, appIcon, description, onSuccess, onClose, push, userProfile?.id, workspacePermissionKeys, isRbacEnabled, setNeedRefresh, invalidateAppList]) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) useHotkey('Mod+Enter', () => { diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 45bb635edcb..fb58e964b75 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -2,10 +2,10 @@ import { act, fireEvent, - render, screen, waitFor, } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { DSLImportMode, DSLImportStatus } from '@/models/app' import { AppModeEnum } from '@/types/app' @@ -247,6 +247,7 @@ describe('CreateFromDSLModal', () => { expect(mockGetRedirection).toHaveBeenCalledWith( { id: 'app-1', mode: 'chat', permission_keys: ['app.acl.view_layout'] }, mockPush, + { isRbacEnabled: false }, ) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index c44e6f3769c..8414b7c6f4f 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -15,6 +16,7 @@ import Input from '@/app/components/base/input' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { DSLImportMode, DSLImportStatus, @@ -55,6 +57,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const [importId, setImportId] = useState() const { handleCheckPluginDependencies } = usePluginDependencies() const setNeedRefresh = useSetNeedRefreshAppList() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const readFile = useCallback((file: File) => { const reader = new FileReader() @@ -129,7 +133,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS invalidateAppList() if (app_id) { await handleCheckPluginDependencies(app_id) - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push) + getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled }) } } else if (status === DSLImportStatus.PENDING) { @@ -184,7 +188,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS setNeedRefresh('1') invalidateAppList() if (app_id) - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push) + getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled }) } else if (status === DSLImportStatus.FAILED) { toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' })) diff --git a/web/app/components/app/in-site-message/__tests__/index.spec.tsx b/web/app/components/app/in-site-message/__tests__/index.spec.tsx index e4b54e82159..2c13ea9cbf5 100644 --- a/web/app/components/app/in-site-message/__tests__/index.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/index.spec.tsx @@ -42,12 +42,14 @@ describe('InSiteMessage', () => { it('should render title, subtitle, markdown content, and action buttons', () => { const actions: InSiteMessageActionItem[] = [ { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }, + { action: 'close', action_name: 'outline', text: 'Outline', type: 'outline' }, { action: 'link', action_name: 'learn_more', text: 'Learn more', type: 'primary', data: 'https://example.com' }, ] renderComponent(actions, { className: 'custom-message' }) const closeButton = screen.getByRole('button', { name: 'Close' }) + const outlineButton = screen.getByRole('button', { name: 'Outline' }) const learnMoreButton = screen.getByRole('button', { name: 'Learn more' }) const panel = closeButton.closest('div.fixed') const titleElement = panel?.querySelector('.title-3xl-bold') @@ -59,6 +61,7 @@ describe('InSiteMessage', () => { expect(subtitleElement?.textContent).not.toContain('\\n') expect(screen.getByText('Main content')).toBeInTheDocument() expect(closeButton).toBeInTheDocument() + expect(outlineButton).toHaveClass('bg-components-button-secondary-bg') expect(learnMoreButton).toBeInTheDocument() }) diff --git a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx index f5171a57c5b..0af00256789 100644 --- a/web/app/components/app/in-site-message/__tests__/notification.spec.tsx +++ b/web/app/components/app/in-site-message/__tests__/notification.spec.tsx @@ -116,6 +116,7 @@ describe('InSiteMessageNotification', () => { main: 'Parsed body main', actions: [ { action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' }, + { action: 'close', text: 'Outline close', type: 'outline' }, { action: 'close', text: 'Dismiss now', type: 'default' }, { action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' }, ], @@ -132,6 +133,7 @@ describe('InSiteMessageNotification', () => { expect(screen.getByText('Parsed body main')).toBeInTheDocument() }) expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Outline close' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument() diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index 4038fb375d9..9452218cf78 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -7,7 +7,7 @@ import { trackEvent } from '@/app/components/base/amplitude' import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive' type InSiteMessageAction = 'link' | 'close' -type InSiteMessageButtonType = 'primary' | 'default' +type InSiteMessageButtonType = 'primary' | 'default' | 'outline' export type InSiteMessageActionItem = { action: InSiteMessageAction @@ -54,6 +54,14 @@ function normalizeLinkData(data: unknown): { href: string, rel?: string, target? const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg' +function resolveButtonVariant(type: InSiteMessageButtonType) { + if (type === 'primary') + return 'primary' + if (type === 'outline') + return 'secondary' + return 'ghost' +} + function InSiteMessage({ notificationId, actions, @@ -132,7 +140,7 @@ function InSiteMessage({ {actions.map(item => ( - )} - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - + + + + )} - onClick={(e) => { - e.stopPropagation() - e.preventDefault() - }} - > - {t('operation.more', { ns: 'common' })} - - - - {systemFeatures.webapp_auth.enabled - ? ( - - ) - : ( - - )} - - - )} - + /> + {starActionLabel} + + {shouldShowOperationsMenu && ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + {t('operation.more', { ns: 'common' })} + + + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + + )} + + )} {showEditModal && ( state.userProfile?.id) const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) + const isRbacEnabled = systemFeatures.rbac_enabled const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() @@ -743,8 +750,10 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement currentUserId, resourceMaintainer, workspacePermissionKeys, - }), [currentUserId, resourceMaintainer, workspacePermissionKeys]) + isRbacEnabled, + }), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys]) const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions]) + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') @@ -871,6 +880,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement currentUserId, resourceMaintainer: getAppResourceMaintainer(newApp), workspacePermissionKeys, + isRbacEnabled, }) } catch { @@ -951,7 +961,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig const shouldShowDeleteOption = appACLCapabilities.canDelete const shouldShowOperationsMenu = shouldShowEditOption || shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption - const shouldShowAppTags = appACLCapabilities.canEdit || canManageAppTags + const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' const editTimeText = useMemo(() => { @@ -995,174 +1005,212 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement const appNameId = useId() const appDescriptionId = useId() const appHref = getRedirectionPath(app, maintainerPermissionOptions) + const appCardClassName = cn( + 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' + : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) const starActionLabel = app.is_starred ? t('studio.unstarApp', { ns: 'app' }) : t('studio.starApp', { ns: 'app' }) + const showPreviewOnlyAccessWarning = useCallback(() => { + toast.warning(t('noAccessResourcePermission', { ns: 'app' })) + }, [t]) + const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + }, [showPreviewOnlyAccessWarning]) + const appCardContent = ( + <> +
+
+ + +
+
+
+
{app.name}
+
+
{appModeLabel}
+
+ {onlinePresenceUsers.length > 0 && ( +
+ +
+ )} +
+
+
+ {app.description} +
+
+
+
+
+
{app.author_name}
+
·
+
{editTimeText}
+
+
+ + ) return ( <>
- + {appCardContent} + + ) + : ( + + {appCardContent} + + )} +
{ + e.stopPropagation() + e.preventDefault() + }} > -
-
- +
+ + {!isPreviewOnly && ( +
+ + + + + )} /> - -
-
-
-
{app.name}
-
-
{appModeLabel}
-
- {onlinePresenceUsers.length > 0 && ( -
- -
+ {starActionLabel} + + {shouldShowOperationsMenu && ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + {t('operation.more', { ns: 'common' })} + + + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + )}
-
-
- {app.description} -
-
-
-
-
-
{app.author_name}
-
·
-
{editTimeText}
-
-
- - {shouldShowAppTags && ( -
{ - e.stopPropagation() - e.preventDefault() - }} - > - -
)} - -
- - - - - )} - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - { - e.stopPropagation() - e.preventDefault() - }} - > - {t('operation.more', { ns: 'common' })} - - - - {systemFeatures.webapp_auth.enabled - ? ( - - ) - : ( - - )} - - - )} -
{showEditModal && (
- - {t('studio.viewSnippets', { ns: 'app' })} - {showCreateButton && ( state.userProfile?.id) const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const editTimeText = useMemo(() => { const timestamp = app.updated_at || app.created_at @@ -36,34 +45,69 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { currentUserId, resourceMaintainer: app.maintainer, workspacePermissionKeys, + isRbacEnabled, }) + const cardClassName = cn( + 'flex h-[72px] min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' + : 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) + const showPreviewOnlyAccessWarning = useCallback(() => { + toast.warning(t('noAccessResourcePermission', { ns: 'app' })) + }, [t]) + const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + }, [showPreviewOnlyAccessWarning]) + const cardContent = ( + <> +
+ + +
+
+
{app.name}
+
+ {app.author_name && {app.author_name}} + {app.author_name && editTimeText && ·} + {editTimeText && {editTimeText}} +
+
+ + ) return (
- -
- - -
-
-
{app.name}
-
- {app.author_name && {app.author_name}} - {app.author_name && editTimeText && ·} - {editTimeText && {editTimeText}} -
-
- - + {isPreviewOnly + ? ( +
+ {cardContent} +
+ ) + : ( + + {cardContent} + + )} + {!isPreviewOnly && }
) } diff --git a/web/app/components/base/zendesk/__tests__/utils.spec.ts b/web/app/components/base/zendesk/__tests__/utils.spec.ts index 7697be3e3fd..da110ff8ecf 100644 --- a/web/app/components/base/zendesk/__tests__/utils.spec.ts +++ b/web/app/components/base/zendesk/__tests__/utils.spec.ts @@ -10,6 +10,7 @@ describe('zendesk/utils', () => { }) afterEach(() => { + vi.useRealTimers() // Clean up window.zE after each test window.zE = mockZE }) @@ -120,4 +121,40 @@ describe('zendesk/utils', () => { expect(window.zE).not.toHaveBeenCalled() }) }) + + describe('openZendeskWindow', () => { + it('should show and open messenger when zE exists', async () => { + vi.doMock('@/config', () => ({ IS_CE_EDITION: false })) + const { openZendeskWindow } = await import('../utils') + + openZendeskWindow() + + expect(window.zE).toHaveBeenCalledWith('messenger', 'show') + expect(window.zE).toHaveBeenCalledWith('messenger', 'open') + }) + + it('should retry opening until zE is ready', async () => { + vi.useFakeTimers() + vi.doMock('@/config', () => ({ IS_CE_EDITION: false })) + const { openZendeskWindow } = await import('../utils') + + window.zE = undefined + openZendeskWindow({ interval: 10, retries: 2 }) + window.zE = mockZE + vi.advanceTimersByTime(10) + + expect(window.zE).toHaveBeenCalledWith('messenger', 'show') + expect(window.zE).toHaveBeenCalledWith('messenger', 'open') + vi.useRealTimers() + }) + + it('should not call window.zE when IS_CE_EDITION is true', async () => { + vi.doMock('@/config', () => ({ IS_CE_EDITION: true })) + const { openZendeskWindow } = await import('../utils') + + openZendeskWindow() + + expect(window.zE).not.toHaveBeenCalled() + }) + }) }) diff --git a/web/app/components/base/zendesk/utils.ts b/web/app/components/base/zendesk/utils.ts index 35f3da74113..825cd887569 100644 --- a/web/app/components/base/zendesk/utils.ts +++ b/web/app/components/base/zendesk/utils.ts @@ -2,7 +2,7 @@ import { IS_CE_EDITION } from '@/config' type ConversationField = { id: string - value: any + value: unknown } declare global { @@ -11,13 +11,13 @@ declare global { zE?: ( command: string, value: string, - payload?: ConversationField[] | string | string[] | (() => any), - callback?: () => any, + payload?: ConversationField[] | string | string[] | (() => unknown), + callback?: () => unknown, ) => void } } -export const setZendeskConversationFields = (fields: ConversationField[], callback?: () => any) => { +export const setZendeskConversationFields = (fields: ConversationField[], callback?: () => unknown) => { if (!IS_CE_EDITION && window.zE) window.zE('messenger:set', 'conversationFields', fields, callback) } @@ -31,3 +31,35 @@ export const toggleZendeskWindow = (open: boolean) => { if (!IS_CE_EDITION && window.zE) window.zE('messenger', open ? 'open' : 'close') } + +type OpenZendeskWindowOptions = { + interval?: number + retries?: number +} + +const openZendeskWindowOnce = () => { + if (IS_CE_EDITION || !window.zE) + return false + + window.zE('messenger', 'show') + window.zE('messenger', 'open') + return true +} + +export const openZendeskWindow = ({ + interval = 100, + retries = 20, +}: OpenZendeskWindowOptions = {}) => { + if (IS_CE_EDITION) + return + + if (openZendeskWindowOnce()) + return + + let attempts = 0 + const timer = window.setInterval(() => { + attempts += 1 + if (openZendeskWindowOnce() || attempts >= retries) + window.clearInterval(timer) + }, interval) +} diff --git a/web/app/components/datasets/access-config/__tests__/index.spec.tsx b/web/app/components/datasets/access-config/__tests__/index.spec.tsx index e93c0ef9433..eeeec0dcf88 100644 --- a/web/app/components/datasets/access-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/access-config/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useDatasetAccessRules, useDatasetUserAccessSettings, @@ -27,6 +28,14 @@ const mockAppContextState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) + const mockAccessRulesEditor = vi.hoisted(() => ({ props: null as AccessRulesEditorProps | null, })) @@ -93,6 +102,7 @@ describe('DatasetAccessConfigPage', () => { } mockAppContextState.userProfile = { id: 'user-1' } mockAppContextState.workspacePermissionKeys = [] + mockIsRbacEnabled = true mockAccessRulesEditor.props = null }) @@ -162,6 +172,16 @@ describe('DatasetAccessConfigPage', () => { expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) }) + it('should disable access config queries and hide the editor when RBAC is disabled', () => { + mockIsRbacEnabled = false + + render() + + expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() + expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) + expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false }) + }) + it('should wire open scope and user policy updates', () => { render() diff --git a/web/app/components/datasets/access-config/index.tsx b/web/app/components/datasets/access-config/index.tsx index fd9a851ccf1..b1df8e3eea7 100644 --- a/web/app/components/datasets/access-config/index.tsx +++ b/web/app/components/datasets/access-config/index.tsx @@ -2,6 +2,7 @@ import type { ResourceOpenScope } from '@/models/access-control' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AccessRulesEditor from '@/app/components/access-rules-editor' @@ -9,6 +10,7 @@ import Loading from '@/app/components/base/loading' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useLocale } from '@/context/i18n' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getAccessControlTemplateLanguage } from '@/i18n-config/language' import { useDatasetAccessRules, @@ -30,11 +32,14 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => const dataset = useDatasetDetailContextWithSelector(state => state.dataset) const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const canAccessConfig = useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { currentUserId, resourceMaintainer: dataset?.maintainer, workspacePermissionKeys, - }).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, workspacePermissionKeys]) + isRbacEnabled, + }).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, isRbacEnabled, workspacePermissionKeys]) const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig }) const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig }) const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = useUpdateDatasetOpenScope(datasetId) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index e58f1796953..d92bf20da36 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ vi.mock('@/next/navigation', () => ({ // Mock useDocLink hook vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`, + useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, })) // Mock external context providers (these are external dependencies) @@ -155,7 +155,7 @@ describe('ExternalKnowledgeBaseCreate', () => { renderComponent() const docLink = screen.getByText('dataset.connectHelper.helper4') - expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base') + expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base') expect(docLink)!.toHaveAttribute('target', '_blank') expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer') }) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 4f85e926ea7..46fe308a720 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { DatasetACLPermission } from '@/utils/permission' import DatasetCardFooter from '../components/dataset-card-footer' import Description from '../components/description' import DatasetCard from '../index' @@ -21,6 +22,23 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ const mockPush = vi.fn() const mockOpenAccessConfig = vi.fn() const mockCloseAccessConfig = vi.fn() +const toastMocks = vi.hoisted(() => { + const record = vi.fn() + const api = Object.assign(vi.fn((message: unknown, options?: Record) => record({ message, ...options })), { + success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options })), + error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options })), + warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options })), + info: vi.fn((message: unknown, options?: Record) => record({ type: 'info', message, ...options })), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }) + return { record, api } +}) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: toastMocks.api, +})) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), @@ -263,6 +281,11 @@ describe('DatasetCard Integration', () => { describe('DatasetCard Component', () => { beforeEach(() => { vi.clearAllMocks() + mockAppContextState = { + isCurrentWorkspaceDatasetOperator: false, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], + } }) it('should render and navigate to documents when clicked', () => { @@ -273,6 +296,52 @@ describe('DatasetCard Component', () => { expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents') }) + it('should render preview-only dataset as a dimmed information-only card', () => { + const dataset = createMockDataset({ + name: 'Preview Only Dataset', + permission_keys: [DatasetACLPermission.Preview], + tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: 0 }], + }) + render() + + const card = screen.getByRole('button', { name: 'Preview Only Dataset' }) + expect(card).toHaveClass('opacity-60') + expect(card).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByText('Preview Only Dataset')).toBeInTheDocument() + const tagArea = screen.getByTestId('tag-area') + expect(tagArea).toHaveAttribute('data-can-bind-or-unbind-tags', 'false') + expect(screen.queryByTestId('operations-dropdown')).not.toBeInTheDocument() + + fireEvent.click(tagArea) + + expect(mockPush).not.toHaveBeenCalled() + expect(toastMocks.record).not.toHaveBeenCalled() + + fireEvent.click(card) + + expect(mockPush).not.toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'warning', + message: 'app.noAccessResourcePermission', + }) + }) + + it('should not navigate preview-only external dataset to a detail page', () => { + const dataset = createMockDataset({ + provider: 'external', + permission_keys: [DatasetACLPermission.Preview], + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Test Dataset' })) + + expect(mockPush).not.toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'warning', + message: 'app.noAccessResourcePermission', + }) + }) + it('should use the hover background treatment', () => { const dataset = createMockDataset() render() @@ -338,6 +407,19 @@ describe('DatasetCard Component', () => { expect(screen.getByTestId('tag-area')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true') }) + it('should allow tag binding with workspace dataset tag management permission', () => { + mockAppContextState = { + isCurrentWorkspaceDatasetOperator: false, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: ['dataset.tag.manage'], + } + const dataset = createMockDataset({ permission_keys: ['dataset.acl.readonly'] }) + + render() + + expect(screen.getByTestId('tag-area')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true') + }) + it('should not allow tag binding when dataset lacks edit ACL', () => { const dataset = createMockDataset({ permission_keys: ['dataset.acl.readonly'] }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 5a6303d83e9..e15135b4281 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -1,11 +1,29 @@ import type { DataSet } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { DatasetACLPermission } from '@/utils/permission' import OperationsDropdown from '../operations-dropdown' +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [] as string[], +})) + +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) + +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)), +})) + describe('OperationsDropdown', () => { const createMockDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -45,6 +63,9 @@ describe('OperationsDropdown', () => { beforeEach(() => { vi.clearAllMocks() + mockAppContextState.userProfile = { id: 'user-1' } + mockAppContextState.workspacePermissionKeys = [] + mockIsRbacEnabled = true }) describe('Rendering', () => { @@ -119,6 +140,19 @@ describe('OperationsDropdown', () => { expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument() }) + + it('should hide resource access option when RBAC is disabled', () => { + mockIsRbacEnabled = false + const dataset = createMockDataset({ + permission_keys: [DatasetACLPermission.AccessConfig, DatasetACLPermission.Delete], + }) + render() + + fireEvent.click(screen.getByLabelText('Dataset operations')) + + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument() + }) }) describe('Styles', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index 4032810a07a..4c7ded2651e 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -5,8 +5,10 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getDatasetACLCapabilities } from '@/utils/permission' import Operations from '../operations' @@ -28,11 +30,14 @@ const OperationsDropdown = ({ const [open, setOpen] = React.useState(false) const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, { currentUserId, resourceMaintainer: dataset.maintainer, workspacePermissionKeys, - }), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys]) + isRbacEnabled, + }), [dataset.maintainer, dataset.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys]) const canShowOperations = datasetACLCapabilities.canEdit || datasetACLCapabilities.canImportExportDSL || datasetACLCapabilities.canAccessConfig diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index aee6b54fd93..0e03449aaf5 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -1,10 +1,14 @@ 'use client' +import type { KeyboardEvent, MouseEvent } from 'react' import type { DataSet } from '@/models/datasets' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags' import { useRouter } from '@/next/navigation' -import { getDatasetACLCapabilities } from '@/utils/permission' +import { getDatasetACLCapabilities, hasOnlyDatasetPreviewPermission, hasPermission } from '@/utils/permission' import CornerLabels from './components/corner-labels' import DatasetCardFooter from './components/dataset-card-footer' import DatasetCardHeader from './components/dataset-card-header' @@ -26,6 +30,7 @@ const DatasetCard = ({ onSuccess, onOpenTagManagement = () => {}, }: DatasetCardProps) => { + const { t } = useTranslation() const { push } = useRouter() const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) @@ -47,14 +52,26 @@ const DatasetCard = ({ const isPipelineUnpublished = useMemo(() => { return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published }, [dataset.runtime_mode, dataset.is_published]) + const isPreviewOnly = hasOnlyDatasetPreviewPermission(dataset.permission_keys) const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, { currentUserId, resourceMaintainer: dataset.maintainer, workspacePermissionKeys, }), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys]) + const canManageAppTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage') + const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || datasetACLCapabilities.canEdit) - const handleCardClick = (e: React.MouseEvent) => { + const showPreviewOnlyAccessWarning = () => { + toast.warning(t('noAccessResourcePermission', { ns: 'app' })) + } + + const handleCardClick = (e: MouseEvent) => { e.preventDefault() + if (isPreviewOnly) { + showPreviewOnlyAccessWarning() + return + } + if (isExternalProvider) { push(datasetACLCapabilities.canRetrievalRecall ? `/datasets/${dataset.id}/hitTesting` @@ -68,17 +85,36 @@ const DatasetCard = ({ } } - const handleTagAreaClick = (e: React.MouseEvent) => { + const handlePreviewOnlyCardKeyDown = (e: KeyboardEvent) => { + if (!isPreviewOnly || (e.key !== 'Enter' && e.key !== ' ')) + return + + e.preventDefault() + showPreviewOnlyAccessWarning() + } + + const handleTagAreaClick = (e: MouseEvent) => { e.stopPropagation() e.preventDefault() } + const cardClassName = cn( + 'group relative col-span-1 flex h-41.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-[background-color,box-shadow] duration-200 ease-in-out', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden' + : 'cursor-pointer hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5', + ) return ( <>
@@ -90,16 +126,18 @@ const DatasetCard = ({ onClick={handleTagAreaClick} onOpenTagManagement={onOpenTagManagement} onTagsChange={onSuccess} - canBindOrUnbindTags={datasetACLCapabilities.canEdit} + canBindOrUnbindTags={canBindOrUnbindTags} /> - + {!isPreviewOnly && ( + + )}
{ + const record = vi.fn() + const api = Object.assign(vi.fn((message: unknown, options?: Record) => record({ message, ...options })), { + success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options })), + error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options })), + warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options })), + info: vi.fn((message: unknown, options?: Record) => record({ type: 'info', message, ...options })), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }) + return { record, api } +}) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: toastMocks.api, +})) vi.mock('@/service/use-explore', () => ({ useLearnDifyAppList: () => ({ @@ -465,6 +482,36 @@ describe('AppList', () => { expect(screen.getByRole('link', { name: 'explore.continueWork.exploreStudio' })).toHaveAttribute('href', '/apps') }) + it('should render preview-only continue work app as a dimmed card and warn on click', () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockWorkspaceApps = [ + createWorkspaceApp({ + id: 'preview-app', + name: 'Preview Only App', + author_name: 'Readonly Author', + permission_keys: [AppACLPermission.Preview], + }), + ] + + renderAppList() + + const card = screen.getByRole('button', { name: 'Preview Only App' }) + expect(card).toHaveClass('opacity-60') + expect(card).toHaveAttribute('aria-disabled', 'true') + expect(screen.queryByRole('link', { name: /Preview Only App/ })).not.toBeInTheDocument() + expect(screen.getByText('Readonly Author')).toBeInTheDocument() + + fireEvent.click(card) + + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'warning', + message: 'app.noAccessResourcePermission', + }) + }) + it('should hide continue work when there are no workspace apps', () => { mockExploreData = { categories: ['Writing'], diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/app/components/explore/continue-work/__tests__/item.spec.tsx new file mode 100644 index 00000000000..275ee584993 --- /dev/null +++ b/web/app/components/explore/continue-work/__tests__/item.spec.tsx @@ -0,0 +1,154 @@ +import type { AnchorHTMLAttributes, ReactNode } from 'react' +import type { App } from '@/types/app' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { AppACLPermission } from '@/utils/permission' +import ContinueWorkItem from '../item' + +const mockAppContext = vi.hoisted(() => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: ['app.create_and_management'], +})) + +const mockFormatTimeFromNow = vi.hoisted(() => vi.fn(() => '5 minutes ago')) + +const toastMocks = vi.hoisted(() => ({ + warning: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: typeof mockAppContext) => unknown) => selector(mockAppContext), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: mockFormatTimeFromNow, + }), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + warning: toastMocks.warning, + }, +})) + +vi.mock('@/next/link', () => ({ + default: ({ + children, + href, + className, + ...props + }: AnchorHTMLAttributes & { children?: ReactNode, href: string }) => ( + {children} + ), +})) + +const createApp = (overrides: Partial = {}): App => ({ + id: 'app-1', + name: 'Continue App', + description: 'Continue app description', + author_name: 'Alice', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: AppModeEnum.CHAT, + enable_site: false, + enable_api: false, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: 100, + maintainer: 'maintainer-1', + updated_at: 200, + site: {} as App['site'], + api_base_url: '', + tags: [], + access_mode: AccessMode.PUBLIC, + permission_keys: [AppACLPermission.Edit], + ...overrides, +}) + +const renderItem = ( + app: App, + systemFeatures: NonNullable[1]>['systemFeatures'] = { rbac_enabled: true }, +) => renderWithSystemFeatures(, { systemFeatures }) + +describe('ContinueWorkItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppContext.userProfile = { id: 'user-1' } + mockAppContext.workspacePermissionKeys = ['app.create_and_management'] + mockFormatTimeFromNow.mockReturnValue('5 minutes ago') + }) + + it('should render a link to the app configuration page when the app is editable', () => { + renderItem(createApp()) + + const link = screen.getByRole('link', { name: /Continue App/ }) + + expect(link).toHaveAttribute('href', '/app/app-1/configuration') + expect(screen.getByText('Alice')).toBeInTheDocument() + expect(screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}')).toBeInTheDocument() + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000) + }) + + it('should use created time when updated time is missing', () => { + renderItem(createApp({ updated_at: 0, created_at: 123 })) + + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(123000) + }) + + it('should link to access config when RBAC is enabled and only access config permission is available', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] })) + + expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/access-config') + }) + + it('should fall back to develop when RBAC is disabled for an access-config-only app', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { rbac_enabled: false }) + + expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/develop') + }) + + it('should render preview-only apps as disabled buttons and warn on click', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.Preview] })) + + const card = screen.getByRole('button', { name: 'Continue App' }) + + expect(card).toHaveAttribute('aria-disabled', 'true') + expect(card).toHaveClass('cursor-not-allowed') + expect(card).toHaveClass('opacity-60') + expect(screen.queryByRole('link', { name: /Continue App/ })).not.toBeInTheDocument() + + fireEvent.click(card) + + expect(toastMocks.warning).toHaveBeenCalledWith('app.noAccessResourcePermission') + }) + + it('should warn when activating a preview-only app with Enter or Space', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.Preview] })) + + const card = screen.getByRole('button', { name: 'Continue App' }) + + fireEvent.keyDown(card, { key: 'Enter' }) + fireEvent.keyDown(card, { key: ' ' }) + + expect(toastMocks.warning).toHaveBeenCalledTimes(2) + expect(toastMocks.warning).toHaveBeenNthCalledWith(1, 'app.noAccessResourcePermission') + expect(toastMocks.warning).toHaveBeenNthCalledWith(2, 'app.noAccessResourcePermission') + }) + + it('should ignore other keys on preview-only app cards', () => { + renderItem(createApp({ permission_keys: [AppACLPermission.Preview] })) + + fireEvent.keyDown(screen.getByRole('button', { name: 'Continue App' }), { key: 'Escape' }) + + expect(toastMocks.warning).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/explore/continue-work/item.tsx b/web/app/components/explore/continue-work/item.tsx index 89f09d57a8f..570dae473c1 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/app/components/explore/continue-work/item.tsx @@ -1,14 +1,19 @@ 'use client' import type { App } from '@/types/app' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' import { useSelector as useAppContextSelector } from '@/context/app-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' import { getRedirectionPath } from '@/utils/app-redirection' +import { hasOnlyAppPreviewPermission } from '@/utils/permission' type ContinueWorkItemProps = { app: App @@ -21,15 +26,35 @@ const ContinueWorkItem = ({ const { formatTimeFromNow } = useFormatTimeFromNow() const currentUserId = useAppContextSelector(state => state.userProfile?.id) const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const updatedAt = (app.updated_at || app.created_at) * 1000 + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const href = getRedirectionPath(app, { currentUserId, resourceMaintainer: app.maintainer, workspacePermissionKeys, + isRbacEnabled, }) + const cardClassName = cn( + 'flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 shadow-xs shadow-shadow-shadow-3', + isPreviewOnly && 'cursor-not-allowed opacity-60', + ) - return ( - + const showPreviewOnlyAccessWarning = () => { + toast.warning(t('noAccessResourcePermission', { ns: 'app' })) + } + + const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + } + + const cardContent = ( + <>
{t('continueWork.editedAt', { ns: 'explore', time: formatTimeFromNow(updatedAt) })}
+ + ) + + if (isPreviewOnly) { + return ( +
+ {cardContent} +
+ ) + } + + return ( + + {cardContent} ) } diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts index f14098e970f..205d93d4124 100644 --- a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts +++ b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts @@ -22,6 +22,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@/context/i18n', () => ({ defaultDocBaseUrl: 'https://docs.dify.ai', + getDocHomePath: () => '/home', })) vi.mock('@/i18n-config/language', () => ({ @@ -45,7 +46,7 @@ describe('docsCommand', () => { docsCommand.execute?.() expect(openSpy).toHaveBeenCalledWith( - expect.stringContaining('https://docs.dify.ai'), + 'https://docs.dify.ai/en/home', '_blank', 'noopener,noreferrer', ) @@ -85,7 +86,11 @@ describe('docsCommand', () => { const handlers = vi.mocked(registerCommands).mock.calls[0]![0] await handlers['navigation.doc']!() - expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer') + expect(openSpy).toHaveBeenCalledWith( + 'https://docs.dify.ai/en/home', + '_blank', + 'noopener,noreferrer', + ) openSpy.mockRestore() }) diff --git a/web/app/components/goto-anything/actions/commands/docs.tsx b/web/app/components/goto-anything/actions/commands/docs.tsx index 6de7a7f9797..f56e0579aff 100644 --- a/web/app/components/goto-anything/actions/commands/docs.tsx +++ b/web/app/components/goto-anything/actions/commands/docs.tsx @@ -2,13 +2,20 @@ import type { SlashCommandHandler } from './types' import { RiBookOpenLine } from '@remixicon/react' import * as React from 'react' import { getI18n } from 'react-i18next' -import { defaultDocBaseUrl } from '@/context/i18n' +import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n' import { getDocLanguage } from '@/i18n-config/language' import { registerCommands, unregisterCommands } from './command-bus' // Documentation command dependency types - no external dependencies needed type DocDeps = Record +const getDocsHomeUrl = () => { + const i18n = getI18n() + const currentLocale = i18n.language + const docLanguage = getDocLanguage(currentLocale) + return `${defaultDocBaseUrl}/${docLanguage}${getDocHomePath()}` +} + /** * Documentation command - Opens help documentation */ @@ -19,11 +26,7 @@ export const docsCommand: SlashCommandHandler = { // Direct execution function execute: () => { - const i18n = getI18n() - const currentLocale = i18n.language - const docLanguage = getDocLanguage(currentLocale) - const url = `${defaultDocBaseUrl}/${docLanguage}` - window.open(url, '_blank', 'noopener,noreferrer') + window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer') }, async search(args: string, locale: string = 'en') { @@ -43,14 +46,9 @@ export const docsCommand: SlashCommandHandler = { }, register(_deps: DocDeps) { - const i18n = getI18n() registerCommands({ 'navigation.doc': async (_args) => { - // Get the current language from i18n - const currentLocale = i18n.language - const docLanguage = getDocLanguage(currentLocale) - const url = `${defaultDocBaseUrl}/${docLanguage}` - window.open(url, '_blank', 'noopener,noreferrer') + window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer') }, }) }, diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index d59199769ed..ec1ac9887d2 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -183,11 +183,13 @@ describe('AccountSetting', () => { initialTab?: AccountSettingTab onCancel?: () => void onTabChange?: (tab: AccountSettingTab) => void + rbacEnabled?: boolean }) => { const { initialTab = ACCOUNT_SETTING_TAB.MEMBERS, onCancel = mockOnCancel, onTabChange = mockOnTabChange, + rbacEnabled = true, } = props ?? {} const StatefulAccountSetting = () => { @@ -211,6 +213,7 @@ describe('AccountSetting', () => { branding: { enabled: false }, enable_marketplace: true, enable_collaboration_mode: false, + rbac_enabled: rbacEnabled, }, }) } @@ -404,6 +407,26 @@ describe('AccountSetting', () => { expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() }) + it('should hide role and resource access entries when RBAC is disabled', () => { + // Act + renderAccountSetting({ rbacEnabled: false }) + + // Assert + expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + }) + + it('should not render direct role pages when RBAC is disabled', () => { + // Act + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.ACCESS_RULES, rbacEnabled: false }) + + // Assert + expect(screen.queryByTestId('access-rules-page')).not.toBeInTheDocument() + expect(screen.queryByTestId('permissions-page')).not.toBeInTheDocument() + expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0) + }) + it('should hide billing and custom tabs when disabled', () => { // Arrange vi.mocked(useProviderContext).mockReturnValue({ diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx index dbbd2dc07eb..5a7edff09bd 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/empty.spec.tsx @@ -11,8 +11,8 @@ describe('Empty State', () => { expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument() const link = screen.getByText('common.apiBasedExtension.link') expect(link).toBeInTheDocument() - // The real useDocLink includes the language prefix (defaulting to /en in tests) - expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension') + // The real useDocLink includes language and product prefixes in tests. + expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension') }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index 5ce1c8f72c1..0318645ea9f 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -11,7 +11,7 @@ import { useInvalidDataSourceList } from '@/service/use-pipeline' import Card from '../card' import { useDataSourceAuthUpdate } from '../hooks' -let mockWorkspacePermissionKeys: string[] = ['credential.manage', 'credential.use'] +let mockWorkspacePermissionKeys: string[] = ['credential.use', 'credential.create', 'credential.manage'] vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ @@ -126,7 +126,7 @@ describe('Card Component', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspacePermissionKeys = ['credential.manage', 'credential.use'] + mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] mockPluginAuthActionReturn = createMockPluginAuthActionReturn() vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate }) @@ -451,7 +451,7 @@ describe('Card Component', () => { expectAuthUpdated() }) - it('should disable configure credential actions when user lacks credential.manage', () => { + it('should disable configure credential actions when user lacks credential.create', () => { // Arrange mockWorkspacePermissionKeys = ['credential.use'] const configurableItem: DataSourceAuth = { diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 0eddcb5d394..a972893e7ec 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -50,7 +50,7 @@ const Card = ({ onPluginUpdate, }: CardProps) => { const { t } = useTranslation() - const { canUseCredential, canManageCredential } = useCredentialPermissions() + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const renderI18nObject = useRenderI18nObject() const { icon, @@ -178,7 +178,7 @@ const Card = ({ pluginPayload={pluginPayload} item={item} onUpdate={handleAuthUpdate} - disabled={disabled || !canManageCredential} + disabled={disabled || !canCreateCredential} />
diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 3d9b0b1a8c9..b04404bde17 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -3,6 +3,7 @@ import type { AccountSettingTab } from '@/app/components/header/account-setting/ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BillingPage from '@/app/components/billing/billing-page' @@ -13,6 +14,7 @@ import { import MenuDialog from '@/app/components/header/account-setting/menu-dialog' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { BillingPermission, hasPermission } from '@/utils/permission' import AccessRulesPage from './access-rules-page' @@ -51,12 +53,18 @@ export default function AccountSetting({ const resetModelProviderListExpanded = useResetModelProviderListExpanded() const { t } = useTranslation() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { workspacePermissionKeys } = useAppContext() - const canManageWorkspaceRoles = hasPermission(workspacePermissionKeys, 'workspace.role.manage') + const isRbacEnabled = systemFeatures.rbac_enabled + const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage') const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View) - const activeMenu = activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling - ? ACCOUNT_SETTING_TAB.LANGUAGE - : activeTab + const activeMenu = (() => { + if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling) + return ACCOUNT_SETTING_TAB.LANGUAGE + if ((activeTab === ACCOUNT_SETTING_TAB.PERMISSIONS || activeTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles) + return ACCOUNT_SETTING_TAB.MEMBERS + return activeTab + })() const scrollContainerRef = useRef(null) const settingItems: GroupItem[] = [ diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx index 16b3dc3fb54..c8ec3a1f2a2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx @@ -3,9 +3,13 @@ import { fireEvent, render, screen } from '@testing-library/react' import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import AddCredentialInLoadBalancing from '../add-credential-in-load-balancing' +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: ['credential.use', 'credential.create', 'credential.manage'], +})) + vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ - workspacePermissionKeys: ['credential.use', 'credential.manage'], + workspacePermissionKeys: mockWorkspacePermissionKeys.value, }), })) @@ -15,13 +19,21 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth' authParams, items, onItemClick, + hideAddAction, + triggerOnlyOpenModal, }: { renderTrigger: (open?: boolean) => React.ReactNode authParams?: { onUpdate?: (payload?: unknown, formValues?: Record) => void } items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }> onItemClick?: (credential: { credential_id: string, credential_name: string }) => void + hideAddAction?: boolean + triggerOnlyOpenModal?: boolean }) => ( -
+
{renderTrigger(false)} @@ -50,6 +62,7 @@ describe('AddCredentialInLoadBalancing', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage'] }) it('should render add credential label', () => { @@ -103,6 +116,45 @@ describe('AddCredentialInLoadBalancing', () => { expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0]) }) + it('should render credential menu for manage-only users with existing credentials', () => { + mockWorkspacePermissionKeys.value = ['credential.manage'] + + render( + , + ) + + expect(screen.getByText(/modelProvider.auth.addCredential/i))!.toBeInTheDocument() + expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-hide-add-action', 'true') + expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-trigger-only-open-modal', 'false') + }) + + it('should render nothing for manage-only users without existing credentials', () => { + mockWorkspacePermissionKeys.value = ['credential.manage'] + + const emptyModelCredential = { + ...modelCredential, + available_credentials: [], + } as ModelCredential + + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + // renderTrigger with open=true: bg-state-base-hover style applied it('should apply hover background when trigger is rendered with open=true', async () => { vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx index 3bfa1029474..7230be997f4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx @@ -24,9 +24,13 @@ vi.mock('../hooks/use-custom-models', () => ({ useCanAddedModels: () => mockCanAddedModels, })) +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: ['credential.use', 'credential.create', 'credential.manage'], +})) + vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ - workspacePermissionKeys: ['credential.manage', 'credential.use'], + workspacePermissionKeys: mockWorkspacePermissionKeys.value, }), })) @@ -60,6 +64,7 @@ describe('AddCustomModel', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage'] mockCanAddedModels = [] }) @@ -120,6 +125,31 @@ describe('AddCustomModel', () => { expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model) }) + it('should show existing model rows as disabled for create-only users', () => { + const model = { model: 'gpt-4', model_type: 'llm' } + mockWorkspacePermissionKeys.value = ['credential.create'] + mockCanAddedModels = [model] + + render( + , + ) + + fireEvent.click(screen.getByTestId('popover-trigger')) + + const modelRow = screen.getByText('gpt-4').closest('[aria-disabled]') + expect(modelRow).toHaveAttribute('aria-disabled', 'true') + expect(modelRow).toHaveClass('cursor-not-allowed') + + fireEvent.click(screen.getByText('gpt-4')) + expect(mockHandleOpenModalForAddCustomModelToModelList).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/)) + expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled() + }) + it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => { mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }] render( diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx index e871b35954f..3199e26692c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx @@ -4,17 +4,40 @@ import userEvent from '@testing-library/user-event' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import SwitchCredentialInLoadBalancing from '../switch-credential-in-load-balancing' +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: ['credential.use', 'credential.create', 'credential.manage'], +})) + vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ - workspacePermissionKeys: ['credential.use', 'credential.manage'], + workspacePermissionKeys: mockWorkspacePermissionKeys.value, }), })) // Mock components vi.mock('../authorized', () => ({ - default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => ( -
-
onItemClick(items[0]!.credentials[0])}> + default: ({ + renderTrigger, + onItemClick, + items, + disabled, + hideAddAction, + triggerOnlyOpenModal, + }: { + renderTrigger: () => React.ReactNode + onItemClick?: (c: unknown) => void + items: { credentials: unknown[] }[] + disabled?: boolean + hideAddAction?: boolean + triggerOnlyOpenModal?: boolean + }) => ( +
+
onItemClick?.(items[0]!.credentials[0])}> {renderTrigger()}
@@ -50,6 +73,7 @@ describe('SwitchCredentialInLoadBalancing', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage'] }) it('should render selected credential name correctly', () => { @@ -82,7 +106,7 @@ describe('SwitchCredentialInLoadBalancing', () => { expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument() }) - it('should render unavailable status when credentials list is empty', () => { + it('should render add credential status when credentials list is empty and create is allowed', () => { render( { />, ) - expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument() + expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument() expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() }) @@ -112,6 +136,27 @@ describe('SwitchCredentialInLoadBalancing', () => { expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0]) }) + it('should keep credential menu available for manage-only users without allowing selection', () => { + mockWorkspacePermissionKeys.value = ['credential.manage'] + + render( + , + ) + + expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-disabled', 'false') + expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-hide-add-action', 'true') + + fireEvent.click(screen.getByTestId('trigger-container')) + + expect(mockSetCustomModelCredential).not.toHaveBeenCalled() + }) + it('should show tooltip when empty and custom credentials not allowed', async () => { const user = userEvent.setup() const restrictedProvider = { ...mockProvider, allow_custom_token: false } @@ -129,8 +174,8 @@ describe('SwitchCredentialInLoadBalancing', () => { expect(await screen.findByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument() }) - // Empty credentials with allowed custom: no tooltip but still shows unavailable text - it('should show unavailable status without tooltip when custom credentials are allowed', () => { + // Empty credentials with allowed custom: no tooltip but still shows add credential text + it('should show add credential status without tooltip when custom credentials are allowed', () => { // Act render( { // Assert // Assert - expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument() + expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument() expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument() }) @@ -231,9 +276,8 @@ describe('SwitchCredentialInLoadBalancing', () => { />, ) - // credentials is undefined → empty=true → unavailable text shown - // credentials is undefined → empty=true → unavailable text shown - expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument() + // credentials is undefined -> empty=true -> add credential text shown when creation is allowed. + expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument() expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx index 1f27fc96788..4bf5002f3a9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx @@ -13,8 +13,7 @@ import { import { useTranslation } from 'react-i18next' import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { Authorized } from '@/app/components/header/account-setting/model-provider-page/model-auth' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' type AddCredentialInLoadBalancingProps = { provider: ModelProvider @@ -36,12 +35,11 @@ const AddCredentialInLoadBalancing = ({ onRemove, }: AddCredentialInLoadBalancingProps) => { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage']) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const { available_credentials, } = modelCredential + const canOpenCredentialMenu = canUseCredential || canCreateCredential || (canManageCredential && !!available_credentials?.length) const isCustomModel = configurationMethod === ConfigurationMethodEnum.customizableModel const notAllowCustomCredential = provider.allow_custom_token === false const handleUpdate = useCallback((payload?: unknown, formValues?: Record) => { @@ -63,7 +61,7 @@ const AddCredentialInLoadBalancing = ({ return Item }, [t]) - if (!canUseCredential) + if (!canOpenCredentialMenu) return null return ( @@ -76,7 +74,7 @@ const AddCredentialInLoadBalancing = ({ onUpdate: handleUpdate, onRemove, }} - triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential && canManageCredential} + triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential && canCreateCredential} items={[ { title: isCustomModel ? '' : t('modelProvider.auth.apiKeys', { ns: 'common' }), @@ -93,7 +91,7 @@ const AddCredentialInLoadBalancing = ({ } : undefined} onItemClick={onSelectCredential} - hideAddAction={!canManageCredential} + hideAddAction={!canCreateCredential} placement="bottom-start" popupTitle={isCustomModel ? t('modelProvider.auth.modelCredentials', { ns: 'common' }) : ''} /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx index d7c677b06c4..def6e42fffe 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx @@ -24,8 +24,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import ModelIcon from '../model-icon' import { useAuth } from './hooks/use-auth' import { useCanAddedModels } from './hooks/use-custom-models' @@ -46,9 +45,7 @@ const AddCustomModel = ({ const [open, setOpen] = useState(false) const canAddedModels = useCanAddedModels(provider) const noModels = !canAddedModels.length - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage']) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canUseCredential, canCreateCredential } = useCredentialPermissions() const { handleOpenModal: handleOpenModalForAddNewCustomModel, } = useAuth( @@ -73,7 +70,9 @@ const AddCustomModel = ({ ) const notAllowCustomCredential = provider.allow_custom_token === false const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => { - const disabled = (noModels && !canManageCredential) || (!noModels && !canUseCredential) + const disabled = noModels + ? !canCreateCredential + : !canUseCredential && !canCreateCredential const item = ( ) - if ((empty && notAllowCustomCredential) || !canUseCredential) { + if ((empty && notAllowCustomCredential) || !canOpenCredentialMenu) { return ( @@ -106,7 +110,7 @@ const SwitchCredentialInLoadBalancing = ({ ) } return Item - }, [canUseCredential, customModelCredential, t, credentials, notAllowCustomCredential]) + }, [canCreateCredential, canOpenCredentialMenu, customModelCredential, t, credentials, notAllowCustomCredential]) return ( ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx index cfb241be7d5..e1a8ac24cf2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx @@ -79,7 +79,7 @@ vi.mock('../../model-auth/hooks', () => ({ vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => - selector({ workspacePermissionKeys: ['credential.manage', 'credential.use'] }), + selector({ workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] }), })) vi.mock('@/hooks/use-i18n', () => ({ diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx index f1d672299c0..b3dad54e25e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx @@ -29,7 +29,7 @@ const mockState = vi.hoisted(() => ({ credentialData: { credentials: {}, available_credentials: [] } as CredentialData, doingAction: false, deleteCredentialId: null as string | null, - workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[], formSchemas: [] as CredentialFormSchema[], formValues: {} as Record, modelNameAndTypeFormSchemas: [] as CredentialFormSchema[], @@ -184,7 +184,7 @@ describe('ModelModal', () => { mockState.credentialData = { credentials: {}, available_credentials: [] } mockState.doingAction = false mockState.deleteCredentialId = null - mockState.workspacePermissionKeys = ['credential.manage', 'credential.use'] + mockState.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] mockState.formSchemas = [] mockState.formValues = {} mockState.modelNameAndTypeFormSchemas = [] diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx index 0c7f68fb361..245554c5c1b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx @@ -41,9 +41,8 @@ import { useCredentialData, } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks' import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { useRenderI18nObject } from '@/hooks/use-i18n' -import { hasPermission } from '@/utils/permission' import { ConfigurationMethodEnum, FormTypeEnum, @@ -107,9 +106,7 @@ const ModelModal: FC = ({ available_credentials, } = credentialData as any - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage']) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const { t } = useTranslation() const language = useLanguage() const { @@ -135,7 +132,8 @@ const ModelModal: FC = ({ return } - if (!canManageCredential) + const canSubmitCredentialForm = credential ? canManageCredential : canCreateCredential + if (!canSubmitCredentialForm) return let modelNameAndTypeIsCheckValidated = true @@ -197,7 +195,7 @@ const ModelModal: FC = ({ }) } onSave(values) - }, [mode, selectedCredential, model, canUseCredential, canManageCredential, onSave, handleActiveCredential, onCancel, handleSaveCredential, credential?.credential_id]) + }, [mode, selectedCredential, model, canUseCredential, canCreateCredential, canManageCredential, onSave, handleActiveCredential, onCancel, handleSaveCredential, credential]) const modalTitle = useMemo(() => { let label = t('modelProvider.auth.apiKeyModal.title', { ns: 'common' }) @@ -277,7 +275,7 @@ const ModelModal: FC = ({ }, [mode, t]) const canSaveCredentialChange = mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential && !selectedCredential.addNewCredential ? canUseCredential - : canManageCredential + : credential ? canManageCredential : canCreateCredential const handleDeleteCredential = useCallback(() => { handleConfirmDelete() @@ -339,7 +337,7 @@ const ModelModal: FC = ({ onSelect={setSelectedCredential} selectedCredential={selectedCredential} disabled={isLoading} - notAllowAddNewCredential={notAllowCustomCredential || !canManageCredential} + notAllowAddNewCredential={notAllowCustomCredential || !canCreateCredential} /> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index f8511a17855..291b903f62a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -72,10 +72,13 @@ vi.mock('@/context/provider-context', () => ({ })) const mockUseAppContext = vi.hoisted(() => vi.fn()) +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: ['credential.use', 'credential.create', 'credential.manage'], +})) vi.mock('@/context/app-context', () => ({ useAppContext: mockUseAppContext, useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ - workspacePermissionKeys: ['credential.manage', 'credential.use'], + workspacePermissionKeys: mockWorkspacePermissionKeys.value, }), })) @@ -136,6 +139,7 @@ const renderWithCombobox = ( describe('PopupItem', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage'] mockUseLanguage.mockReturnValue('en_US') mockUseProviderContext.mockReturnValue({ modelProviders: [makeProvider()], @@ -412,4 +416,18 @@ describe('PopupItem', () => { expect(onHide).toHaveBeenCalled() }) + + it('should keep the credential dropdown enabled for manage-only users', () => { + mockWorkspacePermissionKeys.value = ['credential.manage'] + + renderWithCombobox() + + const trigger = screen.getByRole('button', { name: /my-api-key/ }) + + expect(trigger).not.toBeDisabled() + + fireEvent.click(trigger) + + expect(screen.getByRole('button', { name: 'close dropdown' })).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index a957ae16577..f0d99838c55 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -8,10 +8,9 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations' import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks' import ModelIcon from '../model-icon' @@ -50,11 +49,10 @@ function PopupItem({ const updateModelList = useUpdateModelList() const updateModelProviders = useUpdateModelProviders() const currentProvider = modelProviders.find(provider => provider.provider === model.provider) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canUseCredentials = hasPermission(workspacePermissionKeys, ['credential.manage', 'credential.use']) - const canManageCredentials = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() + const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential const handleOpenModelModal = () => { - if (!canManageCredentials) + if (!canCreateCredential) return if (!currentProvider) @@ -110,7 +108,7 @@ function PopupItem({ {isUsingCredits @@ -142,7 +140,7 @@ function PopupItem({ {t('modelProvider.selector.configureRequired', { ns: 'common' })} )} - {canUseCredentials && } + {canOpenCredentialDropdown && } )} /> @@ -193,7 +191,7 @@ function PopupItem({ onPointerDown={onPreviewCardClose} > {rowContent} - {canManageCredentials && ( + {canCreateCredential && ( +
@@ -97,7 +97,7 @@ describe('DropdownContent', () => { beforeEach(() => { vi.clearAllMocks() mockDeleteCredentialId = null - mockWorkspacePermissionKeys = ['credential.manage', 'credential.use'] + mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] }) describe('UsagePrioritySection visibility', () => { @@ -397,6 +397,58 @@ describe('DropdownContent', () => { expect(mockHandleOpenModal).not.toHaveBeenCalled() expect(mockOpenConfirmDelete).not.toHaveBeenCalled() }) + + it('should allow create-only users to add credentials but not switch, edit, or delete existing credentials', () => { + mockWorkspacePermissionKeys = ['credential.create'] + + render( + , + ) + + fireEvent.click(screen.getByTestId('click-cred-2')) + fireEvent.click(screen.getByTestId('edit-cred-2')) + fireEvent.click(screen.getByTestId('delete-cred-2')) + fireEvent.click(screen.getByRole('button', { name: /addApiKey/ })) + + expect(mockActivate).not.toHaveBeenCalled() + expect(mockOpenConfirmDelete).not.toHaveBeenCalled() + expect(mockHandleOpenModal).toHaveBeenCalledTimes(1) + expect(mockHandleOpenModal).toHaveBeenCalledWith() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should allow manage-only users to edit and delete credentials but not switch or add them', () => { + mockWorkspacePermissionKeys = ['credential.manage'] + + render( + , + ) + + fireEvent.click(screen.getByTestId('click-cred-2')) + fireEvent.click(screen.getByTestId('edit-cred-2')) + fireEvent.click(screen.getByTestId('delete-cred-2')) + + expect(mockActivate).not.toHaveBeenCalled() + expect(mockHandleOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ credential_id: 'cred-2' }), + ) + expect(mockOpenConfirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ credential_id: 'cred-2' }), + ) + expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument() + }) }) describe('Add API Key', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx index eba042cb48f..57caf2c1c2c 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx @@ -2,8 +2,7 @@ import type { Credential, CustomModel, ModelProvider } from '../../declarations' import { Button } from '@langgenius/dify-ui/button' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import CredentialItem from '../../model-auth/authorized/credential-item' type ApiKeySectionProps = { @@ -29,8 +28,7 @@ function ApiKeySection({ }: ApiKeySectionProps) { const { t } = useTranslation() const notAllowCustomCredential = provider.allow_custom_token === false - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() if (!credentials.length) { return ( @@ -45,7 +43,7 @@ function ApiKeySection({
- {!notAllowCustomCredential && canManageCredential && ( + {!notAllowCustomCredential && canCreateCredential && ( + )} + /> + + )} + {!shouldShowUpgradeContact && hasDedicatedChannel && hasZendeskWidget && ( + { + openZendeskWindow() + onContactUsClick?.() + }} + > + + + )} + {!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && ( + + } + /> + + )} ({ href: route.href, @@ -197,7 +198,7 @@ const MainNav = ({ active: route.active, icon: route.icon, activeIcon: route.activeIcon, - })), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, t]) + })), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, systemFeatures.enable_marketplace, t]) const renderLogo = () => { const appTitle = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify' diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 438094a581e..fce275974fd 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -10,13 +10,14 @@ export type MainNavRouteConfig = { icon: string activeIcon: string visibility: MainNavRouteVisibility - feature?: 'agentV2' + feature?: 'agentV2' | 'marketplace' } export type MainNavRouteVisibilityOptions = { agentV2Enabled: boolean canUseAppDeploy: boolean isCurrentWorkspaceDatasetOperator: boolean + marketplaceEnabled: boolean } function isPathUnderRoute(pathname: string, route: string) { @@ -78,6 +79,7 @@ export const MAIN_NAV_ROUTES = [ icon: 'i-custom-vender-main-nav-marketplace', activeIcon: 'i-custom-vender-main-nav-marketplace-active', visibility: 'all', + feature: 'marketplace', }, { key: 'deployments', @@ -94,6 +96,9 @@ export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNa if (route.feature === 'agentV2' && !options.agentV2Enabled) return false + if (route.feature === 'marketplace' && !options.marketplaceEnabled) + return false + if (route.visibility === 'all') return true diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx index 6f8125ac132..c7b66119c79 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -46,7 +46,7 @@ vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { userProfile: typeof mockUserProfile, workspacePermissionKeys: string[] }) => unknown) => selector({ userProfile: mockUserProfile, - workspacePermissionKeys: ['credential.manage', 'credential.use'], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'], }), })) diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx index 97d94cbd86d..b7bd28875c5 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -7,7 +7,7 @@ import { AuthCategory } from '../types' const mockUsePluginAuth = vi.fn() const mockSetShowAccountSettingModal = vi.fn() const mockAppContext = vi.hoisted(() => ({ - workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[], })) vi.mock('../hooks/use-plugin-auth', () => ({ @@ -44,7 +44,7 @@ const defaultPayload = { describe('PluginAuth', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use'] + mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] }) afterEach(() => { @@ -142,7 +142,7 @@ describe('PluginAuth', () => { expect(mockUsePluginAuth).toHaveBeenCalledWith(defaultPayload, true) }) - it('renders permission hint and disables authorization configuration when credential.manage is missing', () => { + it('renders permission hint and disables authorization configuration when credential.create is missing', () => { mockAppContext.workspacePermissionKeys = ['credential.use'] mockUsePluginAuth.mockReturnValue({ isAuthorized: false, diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index aad6a174730..64813aac2a4 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -29,7 +29,7 @@ const createWrapper = () => { // Mock API hooks - only mock network-related hooks const mockGetPluginOAuthClientSchema = vi.fn() const mockAppContext = vi.hoisted(() => ({ - workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[], })) vi.mock('../../hooks/use-credential', () => ({ @@ -94,7 +94,7 @@ const createPluginPayload = (overrides: Partial = {}): PluginPayl describe('Authorize', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use'] + mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] mockGetPluginOAuthClientSchema.mockReturnValue({ schema: [], is_oauth_custom_client_enabled: false, @@ -251,8 +251,8 @@ describe('Authorize', () => { }) }) - describe('credential.manage permission', () => { - it('should disable OAuth button when credential.manage is missing', () => { + describe('credential.create permission', () => { + it('should disable OAuth button when credential.create is missing', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] @@ -267,7 +267,7 @@ describe('Authorize', () => { expect(screen.getByRole('button')).toBeDisabled() }) - it('should disable API Key button when credential.manage is missing', () => { + it('should disable API Key button when credential.create is missing', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] @@ -282,7 +282,7 @@ describe('Authorize', () => { expect(screen.getByRole('button')).toBeDisabled() }) - it('should not disable buttons when credential.manage is present', () => { + it('should not disable buttons when credential.create is present', () => { const pluginPayload = createPluginPayload() render( @@ -548,7 +548,7 @@ describe('Authorize', () => { }).not.toThrow() }) - it('should stay disabled when credential.manage is missing and custom credentials are unavailable', () => { + it('should stay disabled when credential.create is missing and custom credentials are unavailable', () => { const pluginPayload = createPluginPayload() mockAppContext.workspacePermissionKeys = ['credential.use'] diff --git a/web/app/components/plugins/plugin-auth/authorize/index.tsx b/web/app/components/plugins/plugin-auth/authorize/index.tsx index e09afd2d860..be320dad670 100644 --- a/web/app/components/plugins/plugin-auth/authorize/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/index.tsx @@ -8,8 +8,7 @@ import { useMemo, } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import AddApiKeyButton from './add-api-key-button' import AddOAuthButton from './add-oauth-button' @@ -40,8 +39,7 @@ const Authorize = ({ onApiKeyClick, }: AuthorizeProps) => { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') + const { canCreateCredential } = useCredentialPermissions() const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { if (theme === 'secondary') { @@ -84,7 +82,7 @@ const Authorize = ({
@@ -101,14 +99,14 @@ const Authorize = ({ ) } return Item - }, [notAllowCustomCredential, oAuthButtonProps, canManageCredential, onUpdate, t]) + }, [notAllowCustomCredential, oAuthButtonProps, canCreateCredential, onUpdate, t]) const ApiKeyButton = useMemo(() => { const Item = (
@@ -125,7 +123,7 @@ const Authorize = ({ ) } return Item - }, [notAllowCustomCredential, apiKeyButtonProps, canManageCredential, onUpdate, t]) + }, [notAllowCustomCredential, apiKeyButtonProps, canCreateCredential, onUpdate, t]) return ( <> diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 62e34ae2b6c..2ab81fceb94 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -77,7 +77,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas const mockAppContext = vi.hoisted(() => ({ userProfile: { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' }, - workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[], })) vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { @@ -141,7 +141,7 @@ const createCredential = (overrides: Partial = {}): Credential => ({ describe('Authorized Component', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use'] + mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] mockDeletePluginCredential.mockResolvedValue({}) mockSetPluginDefaultCredential.mockResolvedValue({}) mockUpdatePluginCredential.mockResolvedValue({}) diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx index e42b4317c44..58fbe6dfeac 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/item.spec.tsx @@ -12,7 +12,7 @@ vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { userProfile: typeof mockUserProfile, workspacePermissionKeys: string[] }) => unknown) => selector({ userProfile: mockUserProfile, - workspacePermissionKeys: ['credential.manage', 'credential.use'], + workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'], }), })) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx index 7a72e36a4fa..05082315e43 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -79,7 +79,7 @@ const Authorized = ({ notAllowCustomCredential, }: AuthorizedProps) => { const { t } = useTranslation() - const { canUseCredential, canManageCredential } = useCredentialPermissions() + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() const [isLocalOpen, setIsLocalOpen] = useState(false) const mergedIsOpen = isOpen ?? isLocalOpen const setMergedIsOpen = useCallback((open: boolean) => { @@ -152,12 +152,12 @@ const Authorized = ({ // popover closes due to outside-click detection on the modal's portal. const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false) const handleAddApiKeyClick = useCallback(() => { - if (!canManageCredential) + if (!canCreateCredential) return setMergedIsOpen(false) setIsAddApiKeyOpen(true) - }, [canManageCredential, setMergedIsOpen]) + }, [canCreateCredential, setMergedIsOpen]) const handleRemove = useCallback(() => { if (!canManageCredential) return @@ -394,7 +394,7 @@ const Authorized = ({ onOpenChange={setIsAddApiKeyOpen} pluginPayload={pluginPayload} onClose={() => setIsAddApiKeyOpen(false)} - disabled={!canManageCredential || doingAction} + disabled={!canCreateCredential || doingAction} onUpdate={onUpdate} /> ) diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx index 88edd05b6b8..03f8695449b 100644 --- a/web/app/components/plugins/plugin-auth/authorized/item.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -16,7 +16,7 @@ import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import Input from '@/app/components/base/input' import { useSelector as useAppContextWithSelector } from '@/context/app-context' -import { hasPermission } from '@/utils/permission' +import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { CredentialTypeEnum } from '../types' type ItemProps = { @@ -55,9 +55,7 @@ const Item = ({ const { t } = useTranslation() const [renaming, setRenaming] = useState(false) const [renameValue, setRenameValue] = useState(credential.name) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) - const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage') - const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage']) + const { canUseCredential, canManageCredential } = useCredentialPermissions() const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2 const isPersonal = credential.visibility === 'only_me' const userProfile = useAppContextWithSelector(state => state.userProfile) diff --git a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index c15ddb95550..0092eebdd6c 100644 --- a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -15,7 +15,7 @@ vi.mock('@/context/app-context', () => ({ // Mock useLocale and useDocLink vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', - useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : path}`, + useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`, })) // Mock getLanguage @@ -132,7 +132,7 @@ describe('CustomCreateCard', () => { render() const docLink = screen.getByText('tools.swaggerAPIAsToolTip').closest('a') - expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool') + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#custom-tool') expect(docLink).toHaveAttribute('target', '_blank') expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') }) diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index 9aca087cf7d..9bce3c0845d 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -14,7 +14,7 @@ vi.mock('@/i18n-config/language', () => ({ const mockIsCurrentWorkspaceManager = vi.fn(() => true) const mockAppContextState = vi.hoisted(() => ({ - workspacePermissionKeys: ['tool.manage', 'credential.manage', 'credential.use'] as string[], + workspacePermissionKeys: ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] as string[], })) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -173,7 +173,7 @@ describe('ProviderDetail', () => { ]) mockFetchCustomToolList.mockResolvedValue([]) mockFetchModelToolList.mockResolvedValue([]) - mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.manage', 'credential.use'] + mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] }) afterEach(() => { @@ -535,8 +535,8 @@ describe('ProviderDetail', () => { expect(screen.getByTestId('config-credential'))!.toBeInTheDocument() }) - it('does not open setup credential drawer without credential.manage', async () => { - mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use'] + it('does not open setup credential drawer without credential.create', async () => { + mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.manage'] render( { expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i })).toHaveAttribute('href', '/apps') expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('target', '_blank') - expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool') + expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool') }) }) diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index adf993c44d8..747bdff90d8 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -82,8 +82,9 @@ const ProviderDetail = ({ const isAuthed = collection.is_team_authorization const isBuiltIn = collection.type === CollectionType.builtIn const isModel = collection.type === CollectionType.model - const { canUseCredential, canManageCredential } = useCredentialPermissions() - const canOpenCredentialSettings = isAuthed ? canUseCredential : canManageCredential + const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() + const canOpenCredentialSettings = isAuthed ? canUseCredential : canCreateCredential + const canSaveCredentialSettings = isAuthed ? canManageCredential : canCreateCredential const canManageTools = useCanManageTools() const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() const [isDetailLoading, setIsDetailLoading] = useState(false) @@ -432,7 +433,7 @@ const ProviderDetail = ({ collection={collection} onCancel={() => setShowSettingAuth(false)} onSaved={async (value) => { - if (!canManageCredential) + if (!canSaveCredentialSettings) return await updateBuiltInToolCredential(collection.name, value) @@ -449,7 +450,7 @@ const ProviderDetail = ({ await onRefreshData() setShowSettingAuth(false) }} - readonly={!canManageCredential} + readonly={!canSaveCredentialSettings} /> )} {isShowEditCollectionToolModal && canManageTools && ( diff --git a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts index 17083cc87b9..4afcbd07387 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -54,6 +54,18 @@ describe('useAvailableNodesMetaData', () => { }) }) + it('should use explicit docs pages and skip nodes without generated docs pages', () => { + mockUseIsChatMode.mockReturnValue(false) + + const { result } = renderHook(() => useAvailableNodesMetaData()) + + expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/output') + expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input') + }) + it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => { mockUseIsChatMode.mockReturnValue(false) diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index b001c381898..798181df3c7 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -14,8 +14,20 @@ import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webho import { BlockEnum } from '@/app/components/workflow/types' import { useDocLink } from '@/context/i18n' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' +import { docPathProductAvailability } from '@/types/doc-paths' import { useIsChatMode } from './use-is-chat-mode' +const getNodeHelpLinkPath = (helpLinkUri?: string): DocPathWithoutLang | undefined => { + if (!helpLinkUri) + return undefined + + const helpLinkPath = `/use-dify/nodes/${helpLinkUri}` + if (!docPathProductAvailability[helpLinkPath]) + return undefined + + return helpLinkPath as DocPathWithoutLang +} + export const useAvailableNodesMetaData = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() @@ -57,14 +69,14 @@ export const useAvailableNodesMetaData = () => { const { metaData } = node const title = t(`blocks.${metaData.type}`, { ns: 'workflow' }) const description = t(`blocksAbout.${metaData.type}` as I18nKeysWithPrefix<'workflow', 'blocksAbout.'>, { ns: 'workflow' }) - const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang + const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri) return { ...node, metaData: { ...metaData, title, description, - helpLinkUri: docLink(helpLinkPath), + helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined, }, defaultValue: { ...node.defaultValue, diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 3ae8851a23f..b58fa5efc51 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -3,10 +3,8 @@ import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useEffect } from 'react' import { useNodes } from 'reactflow' -import { PipelineInputVarType } from '@/models/pipeline' import { SelectionContextmenu } from '../selection-contextmenu' import { useWorkflowStore } from '../store' -import { BlockEnum } from '../types' import { useWorkflowHistoryStore } from '../workflow-history-store' import { createEdge, createNode } from './fixtures' import { renderWorkflowFlowComponent } from './workflow-test-env' @@ -17,48 +15,6 @@ const mockGetNodesReadOnly = vi.fn() const mockHandleNodesCopy = vi.fn() const mockHandleNodesDuplicate = vi.fn() const mockHandleNodesDelete = vi.fn() -const mockHandleCreateSnippet = vi.fn() -const mockCreateSnippetDialogRender = vi.fn() -const mockWorkspacePermissionKeys = vi.hoisted(() => ({ - value: ['snippets.create_and_modify'] as string[], -})) - -vi.mock('@/context/app-context', () => ({ - useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ - workspacePermissionKeys: mockWorkspacePermissionKeys.value, - }), -})) - -vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => { - const React = await vi.importActual('react') - - return { - useCreateSnippet: () => { - const [isOpen, setIsOpen] = React.useState(false) - - return { - createSnippetMutation: { isPending: false }, - handleCloseCreateSnippetDialog: () => setIsOpen(false), - handleCreateSnippet: mockHandleCreateSnippet, - handleOpenCreateSnippetDialog: () => setIsOpen(true), - isCreateSnippetDialogOpen: isOpen, - isCreatingSnippet: false, - } - }, - } -}) - -vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ - default: (props: { - isOpen: boolean - selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } } - inputFields?: Array<{ variable: string }> - }) => { - mockCreateSnippetDialogRender(props) - - return props.isOpen ?
: null - }, -})) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') @@ -142,9 +98,6 @@ describe('SelectionContextmenu', () => { mockHandleNodesCopy.mockReset() mockHandleNodesDuplicate.mockReset() mockHandleNodesDelete.mockReset() - mockHandleCreateSnippet.mockReset() - mockCreateSnippetDialogRender.mockReset() - mockWorkspacePermissionKeys.value = ['snippets.create_and_modify'] }) it('should not render when selection context menu target is absent', () => { @@ -203,41 +156,7 @@ describe('SelectionContextmenu', () => { expect(store.getState().contextMenuTarget).toBeUndefined() }) - it('should open create snippet dialog with selected graph from the top menu item', async () => { - const nodes = [ - createNode({ id: 'n1', selected: true, width: 80, height: 40 }), - createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), - createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }), - ] - const edges = [ - createEdge({ source: 'n1', target: 'n2' }), - createEdge({ source: 'n2', target: 'n3' }), - ] - const { store } = renderSelectionMenu({ nodes, edges }) - - act(() => { - store.setState({ contextMenuTarget: { type: 'selection' } }) - }) - - fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) - - expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() - expect(store.getState().contextMenuTarget).toBeUndefined() - - const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] - expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2']) - expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true) - expect(dialogProps.selectedGraph.edges).toHaveLength(1) - expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 }) - expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({ - source: 'n1', - target: 'n2', - selected: false, - })) - }) - - it('should hide create snippet action without snippets create-and-modify permission', async () => { - mockWorkspacePermissionKeys.value = [] + it('should hide create snippet action for selected nodes', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), @@ -252,76 +171,7 @@ describe('SelectionContextmenu', () => { expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() - }) - - it('should add input fields for variable references outside of the selected graph', async () => { - const nodes = [ - createNode({ - id: 'n1', - selected: true, - width: 80, - height: 40, - data: { - prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}', - query_variable_selector: ['source-node', 'topic'], - env_reference: '{{#env.API_KEY#}}', - }, - }), - createNode({ - id: 'n2', - selected: true, - position: { x: 140, y: 0 }, - width: 80, - height: 40, - }), - ] - const { store } = renderSelectionMenu({ nodes }) - - act(() => { - store.setState({ contextMenuTarget: { type: 'selection' } }) - }) - - fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) - - const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] - expect(dialogProps.inputFields).toEqual([ - { - label: 'topic', - variable: 'topic', - type: PipelineInputVarType.textInput, - required: true, - }, - { - label: 'API_KEY', - variable: 'API_KEY', - type: PipelineInputVarType.textInput, - required: true, - }, - ]) - expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}') - expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic']) - expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}') - }) - - it.each([ - BlockEnum.Answer, - BlockEnum.End, - BlockEnum.Start, - ])('should hide create snippet when selection contains %s node', async (nodeType) => { - const nodes = [ - createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }), - createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), - ] - const { store } = renderSelectionMenu({ nodes }) - - act(() => { - store.setState({ contextMenuTarget: { type: 'selection' } }) - }) - - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() - }) - expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() + expect(screen.queryByTestId('create-snippet-dialog')).not.toBeInTheDocument() }) it('should stay hidden when only one node is selected', async () => { diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index aaf26eedc56..b7248dcb90a 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -106,6 +106,7 @@ describe('NodeSelector', () => { await user.click(trigger) const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') + expect(screen.queryByText('workflow.tabs.snippets')).not.toBeInTheDocument() expect(screen.getByText('LLM')).toBeInTheDocument() expect(screen.getByText('End')).toBeInTheDocument() @@ -317,7 +318,7 @@ describe('NodeSelector', () => { expect(await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' })).toHaveAttribute( 'href', - 'https://docs.dify.ai/en/use-dify/nodes/trigger/overview', + 'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview', ) expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 595426a262b..6678f081fe9 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -132,7 +132,7 @@ function NodeSelector({ const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection const disableStartTab = flowType === FlowType.snippet - const disableSnippetsTab = flowType === FlowType.snippet + const disableSnippetsTab = true const { activeTab, resetActiveTab, diff --git a/web/app/components/workflow/nodes/end/default.ts b/web/app/components/workflow/nodes/end/default.ts index 146ffc6797b..98dcbc2637d 100644 --- a/web/app/components/workflow/nodes/end/default.ts +++ b/web/app/components/workflow/nodes/end/default.ts @@ -6,6 +6,7 @@ import { genNodeMetaData } from '@/app/components/workflow/utils' const metaData = genNodeMetaData({ sort: 2.1, type: BlockEnum.End, + helpLinkUri: 'output', isRequired: false, }) const nodeDefault: NodeDefault = { diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index dffbd31a5e1..0478b0e12f5 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -12,15 +12,11 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' -import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection' -import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore, useWorkflowStore } from './store' -import { BlockEnum } from './types' const AlignType = { Bottom: 'bottom', @@ -75,14 +71,6 @@ const menuSections: MenuSection[] = [ }, ] -const unsupportedSnippetNodeTypes = new Set([ - BlockEnum.Answer, - BlockEnum.End, - BlockEnum.Start, - BlockEnum.HumanInput, - BlockEnum.KnowledgeRetrieval, -]) - const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => { const selectedNodeIds = new Set(selectedNodes.map(node => node.id)) const childNodeIds = new Set() @@ -235,7 +223,6 @@ export function SelectionContextmenu({ }) { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection') @@ -247,20 +234,8 @@ export function SelectionContextmenu({ const selectedNodes = useReactFlowStore(state => state.getNodes().filter(node => node.selected), ) - const edges = useReactFlowStore(state => state.edges) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() - const { - createSnippetDialog, - handleOpenCreateSnippet, - isCreateSnippetDialogOpen, - } = useCreateSnippetFromSelection({ - edges, - selectedNodes, - onClose, - }) - const canCreateSnippet = canCreateAndModifySnippets(workspacePermissionKeys) - && selectedNodes.every(node => !unsupportedSnippetNodeTypes.has(node.data.type)) const handleCopyNodes = useCallback(() => { handleNodesCopy() @@ -370,24 +345,11 @@ export function SelectionContextmenu({ }, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose]) if (!isSelectionContextMenu || selectedNodes.length <= 1) - return isCreateSnippetDialogOpen ? createSnippetDialog : null + return null return ( <> - {canCreateSnippet && ( - <> - - - {t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })} - - - - - )} ))} - {createSnippetDialog} ) } diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx index fade26f0482..e96fe5306ae 100644 --- a/web/app/components/workflow/workflow-generator/index.tsx +++ b/web/app/components/workflow/workflow-generator/index.tsx @@ -15,6 +15,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' @@ -25,6 +26,7 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import WorkflowPreview from '@/app/components/workflow/workflow-preview' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter } from '@/next/navigation' import { generateWorkflow } from '@/service/debug' import { fetchWorkflowDraft } from '@/service/workflow' @@ -97,6 +99,8 @@ const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, c const WorkflowGeneratorModal: React.FC = () => { const { t } = useTranslation('workflow') const router = useRouter() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isRbacEnabled = systemFeatures.rbac_enabled const isOpen = useWorkflowGeneratorStore(s => s.isOpen) const mode = useWorkflowGeneratorStore(s => s.mode) @@ -347,7 +351,7 @@ const WorkflowGeneratorModal: React.FC = () => { }) toast.success(t('workflowGenerator.applied')) closeGenerator() - router.push(getRedirectionPath({ id: appId, mode: appMode, permission_keys: permissionKeys })) + router.push(getRedirectionPath({ id: appId, mode: appMode, permission_keys: permissionKeys }, { isRbacEnabled })) } catch (e: unknown) { if (e instanceof WorkflowApplyOrphanError) { @@ -364,7 +368,7 @@ const WorkflowGeneratorModal: React.FC = () => { finally { setApplyingFalse() } - }, [current, instruction, mode, router, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse]) + }, [current, instruction, mode, router, closeGenerator, t, isApplying, isRbacEnabled, setApplyingTrue, setApplyingFalse]) const handleApplyToCurrentConfirmed = useCallback(async () => { if (!current?.graph || !currentAppId || isApplying) diff --git a/web/context/i18n.spec.ts b/web/context/i18n.spec.ts index 83e510f040c..0fc0ae5c809 100644 --- a/web/context/i18n.spec.ts +++ b/web/context/i18n.spec.ts @@ -5,6 +5,10 @@ import { useTranslation } from '#i18n' import { getDocLanguage } from '@/i18n-config/language' import { defaultDocBaseUrl, useDocLink } from './i18n' +const mockConfig = vi.hoisted(() => ({ + IS_CLOUD_EDITION: true, +})) + // Mock dependencies vi.mock('#i18n', () => ({ useTranslation: vi.fn(() => ({ @@ -12,6 +16,12 @@ vi.mock('#i18n', () => ({ })), })) +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return mockConfig.IS_CLOUD_EDITION + }, +})) + vi.mock('@/i18n-config/language', () => ({ getDocLanguage: vi.fn((locale: string) => { const map: Record = { @@ -28,6 +38,7 @@ vi.mock('@/i18n-config/language', () => ({ describe('useDocLink', () => { beforeEach(() => { vi.clearAllMocks() + mockConfig.IS_CLOUD_EDITION = true vi.mocked(useTranslation).mockReturnValue({ i18n: { language: 'en-US' }, } as ReturnType) @@ -45,28 +56,28 @@ describe('useDocLink', () => { it('should use default base URL when no baseUrl provided', () => { const { result } = renderHook(() => useDocLink()) const url = result.current() - expect(url).toBe(`${defaultDocBaseUrl}/en`) + expect(url).toBe(`${defaultDocBaseUrl}/en/home`) }) it('should use custom base URL when provided', () => { const customBaseUrl = 'https://custom.docs.com' const { result } = renderHook(() => useDocLink(customBaseUrl)) const url = result.current() - expect(url).toBe(`${customBaseUrl}/en`) + expect(url).toBe(`${customBaseUrl}/en/home`) }) it('should remove trailing slash from base URL', () => { const baseUrlWithSlash = 'https://docs.dify.ai/' const { result } = renderHook(() => useDocLink(baseUrlWithSlash)) const url = result.current('/use-dify/getting-started/introduction') - expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction') + expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction') }) it('should handle base URL without trailing slash', () => { const baseUrlWithoutSlash = 'https://docs.dify.ai' const { result } = renderHook(() => useDocLink(baseUrlWithoutSlash)) const url = result.current('/use-dify/getting-started/introduction') - expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction') + expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction') }) }) @@ -74,19 +85,31 @@ describe('useDocLink', () => { it('should handle path parameter', () => { const { result } = renderHook(() => useDocLink()) const url = result.current('/use-dify/getting-started/introduction') - expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`) + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`) }) it('should handle empty path', () => { const { result } = renderHook(() => useDocLink()) const url = result.current() - expect(url).toBe(`${defaultDocBaseUrl}/en`) + expect(url).toBe(`${defaultDocBaseUrl}/en/home`) }) it('should handle undefined path', () => { const { result } = renderHook(() => useDocLink()) const url = result.current(undefined) - expect(url).toBe(`${defaultDocBaseUrl}/en`) + expect(url).toBe(`${defaultDocBaseUrl}/en/home`) + }) + + it('should keep common docs path without product prefix', () => { + const { result } = renderHook(() => useDocLink()) + const url = result.current('/learn/key-concepts' as DocPathWithoutLang) + expect(url).toBe(`${defaultDocBaseUrl}/en/learn/key-concepts`) + }) + + it('should keep explicit product docs path without adding another product prefix', () => { + const { result } = renderHook(() => useDocLink()) + const url = result.current('/cloud/use-dify/build/mcp' as DocPathWithoutLang) + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`) }) }) @@ -99,12 +122,12 @@ describe('useDocLink', () => { const pathMap: DocPathMap = { 'zh-Hans': '/use-dify/getting-started/introduction', - 'en-US': '/use-dify/getting-started/quick-start', + 'en-US': '/use-dify/build/mcp', } const { result } = renderHook(() => useDocLink()) - const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap) - expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`) + const url = result.current('/use-dify/build/mcp', pathMap) + expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`) }) it('should use default path when locale not in pathMap', () => { @@ -115,18 +138,76 @@ describe('useDocLink', () => { const pathMap: DocPathMap = { 'zh-Hans': '/use-dify/getting-started/introduction', - 'en-US': '/use-dify/getting-started/quick-start', + 'en-US': '/use-dify/build/mcp', } const { result } = renderHook(() => useDocLink()) - const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap) - expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`) + const url = result.current('/use-dify/build/mcp', pathMap) + expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`) }) it('should handle undefined pathMap', () => { const { result } = renderHook(() => useDocLink()) const url = result.current('/use-dify/getting-started/introduction', undefined) - expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`) + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`) + }) + }) + + describe('Product prefix handling', () => { + it('should add cloud product prefix for product docs available in both editions', () => { + mockConfig.IS_CLOUD_EDITION = true + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/use-dify/build/mcp') + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`) + }) + + it('should add self-host product prefix for product docs available in both editions outside cloud edition', () => { + mockConfig.IS_CLOUD_EDITION = false + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/use-dify/build/mcp') + expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/build/mcp`) + }) + + it('should use the existing cloud docs path for cloud-only product docs outside cloud edition', () => { + mockConfig.IS_CLOUD_EDITION = false + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/use-dify/workspace/subscription-management#dify-for-education') + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`) + }) + + it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => { + mockConfig.IS_CLOUD_EDITION = true + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/deploy/overview') + expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`) + }) + + it('should not add a product prefix for unknown productless paths', () => { + mockConfig.IS_CLOUD_EDITION = false + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/use-dify/unknown-page' as DocPathWithoutLang) + expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/unknown-page`) + }) + + it('should open shared docs home when no path is provided outside cloud edition', () => { + mockConfig.IS_CLOUD_EDITION = false + + const { result } = renderHook(() => useDocLink()) + const url = result.current() + expect(url).toBe(`${defaultDocBaseUrl}/en/home`) + }) + + it('should keep self-host deploy paths without adding use-dify product prefix', () => { + mockConfig.IS_CLOUD_EDITION = true + + const { result } = renderHook(() => useDocLink()) + const url = result.current('/self-host/deploy/overview' as DocPathWithoutLang) + expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`) }) }) @@ -232,7 +313,7 @@ describe('useDocLink', () => { const { result } = renderHook(() => useDocLink()) const url = result.current('/use-dify/getting-started/introduction') - expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`) + expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`) }) }) @@ -240,15 +321,15 @@ describe('useDocLink', () => { it('should handle path with anchor', () => { const { result } = renderHook(() => useDocLink()) const url = result.current('/use-dify/getting-started/introduction#overview' as DocPathWithoutLang) - expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction#overview`) + expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`) }) it('should handle multiple calls with same hook instance', () => { const { result } = renderHook(() => useDocLink()) const url1 = result.current('/use-dify/getting-started/introduction') - const url2 = result.current('/use-dify/getting-started/quick-start') - expect(url1).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`) - expect(url2).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/quick-start`) + const url2 = result.current('/use-dify/build/mcp') + expect(url1).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`) + expect(url2).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`) }) }) }) diff --git a/web/context/i18n.ts b/web/context/i18n.ts index ac11f44f323..a8f7dfa5959 100644 --- a/web/context/i18n.ts +++ b/web/context/i18n.ts @@ -1,9 +1,10 @@ import type { Locale } from '@/i18n-config/language' -import type { DocPathWithoutLang } from '@/types/doc-paths' +import type { DocPathWithoutLang, DocsProduct } from '@/types/doc-paths' import { useCallback } from 'react' import { useTranslation } from '#i18n' +import { IS_CLOUD_EDITION } from '@/config' import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language' -import { apiReferencePathTranslations } from '@/types/doc-paths' +import { apiReferencePathTranslations, docPathProductAvailability } from '@/types/doc-paths' export const useLocale = () => { const { i18n } = useTranslation() @@ -24,6 +25,44 @@ export const useGetPricingPageLanguage = () => { export const defaultDocBaseUrl = 'https://docs.dify.ai' export type DocPathMap = Partial> +export const getDocHomePath = () => '/home' + +const getCurrentDocsProduct = (): DocsProduct => { + return IS_CLOUD_EDITION ? 'cloud' : 'self-host' +} + +const splitPathHash = (path: string) => { + const hashIndex = path.indexOf('#') + if (hashIndex === -1) { + return { + pathname: path, + hash: '', + } + } + + return { + pathname: path.slice(0, hashIndex), + hash: path.slice(hashIndex), + } +} + +const getProductAwarePath = (path: string): string => { + const { pathname, hash } = splitPathHash(path) + const availableProducts = docPathProductAvailability[pathname] + if (!availableProducts?.length) + return path + + const currentProduct = getCurrentDocsProduct() + const targetProduct = availableProducts.includes(currentProduct) + ? currentProduct + : availableProducts[0] + + if (!targetProduct) + return path + + return `/${targetProduct}${pathname}${hash}` +} + export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => { let baseDocUrl = baseUrl || defaultDocBaseUrl baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl @@ -44,6 +83,12 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM } } } + else if (!targetPath) { + targetPath = getDocHomePath() + } + else { + targetPath = getProductAwarePath(targetPath) + } return `${baseDocUrl}${languagePrefix}${targetPath}` }, diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index 6b3e2af6a06..a22373e7bfe 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -84,7 +84,7 @@ export function AgentAccessPage({

{t('agentDetail.access.description')} ({ + atomWithInfiniteQuery: (createOptions: (get: Getter) => Record) => atom((get) => { + const options = createOptions(get) + + return { + ...options, + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + } + }), + atomWithMutation: () => atom(() => ({ + isPending: false, + mutateAsync: vi.fn(), + })), + atomWithQuery: (createOptions: (get: Getter) => Record) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + list: { + infiniteOptions: (options: Record) => ({ + ...options, + queryKey: ['apps', 'list'], + }), + }, + }, + }, +})) + +async function loadState() { + return await import('../index') +} + +describe('create deployment guide state', () => { + it('should keep the guide on source app mode when DSL import is disabled', async () => { + const state = await loadState() + const store = createStore() + + store.set(state.selectMethodAtom, 'importDsl') + + expect(store.get(state.methodAtom)).toBe('bindApp') + expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + }) + + it('should keep source app loading enabled if stale state points to DSL import', async () => { + const state = await loadState() + const store = createStore() + + store.set(state.methodAtom, 'importDsl') + + const sourceAppsQuery = store.get(state.sourceAppsQueryAtom) as unknown as { enabled?: boolean } + + expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + expect(sourceAppsQuery.enabled).toBe(true) + }) +}) diff --git a/web/features/deployments/create-guide/state/index.ts b/web/features/deployments/create-guide/state/index.ts index 06dab290172..8108780bff8 100644 --- a/web/features/deployments/create-guide/state/index.ts +++ b/web/features/deployments/create-guide/state/index.ts @@ -27,6 +27,7 @@ import { isWorkflowDsl, } from '@/features/deployments/shared/domain/dsl' import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error' +import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency' import { DEPLOYMENT_PAGE_SIZE, @@ -41,6 +42,12 @@ export type GuideMethod = 'bindApp' | 'importDsl' export type GuideStep = 'source' | 'release' | 'target' export type WorkflowSourceApp = App & { mode: Extract } +function deploymentGuideMethod(method: GuideMethod): GuideMethod { + return method === 'importDsl' && !isDeploymentDslImportEnabled + ? 'bindApp' + : method +} + const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' const RANDOM_SUFFIX_LENGTH = 4 const RANDOM_SUFFIX_FALLBACK_LENGTH = 6 @@ -124,6 +131,7 @@ function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | // Workflow primitives export const stepAtom = atom('source') export const methodAtom = atom('bindApp') +export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom))) // Source primitives export const sourceSearchTextAtom = atom('') @@ -145,7 +153,7 @@ export const dslDefaultAppNameAtom = atom((get) => { export const dslUnsupportedModeAtom = atom((get) => { const dslContent = get(dslContentAtom) - return get(methodAtom) === 'importDsl' + return get(effectiveMethodAtom) === 'importDsl' && Boolean(dslContent.trim()) && !get(isReadingDslAtom) && !get(dslReadErrorAtom) @@ -199,7 +207,7 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { initialPageParam: 1, placeholderData: keepPreviousData, }), - enabled: get(methodAtom) === 'bindApp', + enabled: get(effectiveMethodAtom) === 'bindApp', } }) @@ -218,7 +226,7 @@ export const effectiveSelectedAppAtom = atom((get) => { }) function sourceReady(get: Getter) { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) return method === 'importDsl' ? get(importDslReadyAtom) @@ -269,7 +277,7 @@ export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => { }) const precheckReleaseQueryAtom = atomWithQuery((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const dslContent = get(dslContentAtom) const enabled = sourceReady(get) @@ -310,7 +318,7 @@ function precheckReleaseReady(get: Getter) { } export const deploymentOptionsQueryAtom = atomWithQuery((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const dslContent = get(dslContentAtom) const enabled = precheckReleaseReady(get) @@ -378,7 +386,7 @@ const deploymentOptionsContentCheckedAtom = atom((get) => { }) export const sourceCanGoNextAtom = atom((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const importDslReady = method === 'importDsl' && get(importDslReadyAtom) const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) @@ -416,7 +424,7 @@ export const continueFromSourceAtom = atom(null, (get, set, { if (!get(sourceCanGoNextAtom)) return - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) if (method === 'bindApp' && effectiveSelectedApp) set(selectSourceAppAtom, effectiveSelectedApp) @@ -606,7 +614,7 @@ const requiredBindingsReadyAtom = atom((get) => { }) export const deploymentTargetEnvVarSlotsAtom = atom((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined const dslContent = get(dslContentAtom) @@ -702,7 +710,7 @@ export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarVal // Workflow actions export const selectMethodAtom = atom(null, (_get, set, method: GuideMethod) => { - set(methodAtom, method) + set(methodAtom, deploymentGuideMethod(method)) set(selectedEnvironmentIdAtom, '') set(manualBindingSelectionsAtom, {}) set(envVarValuesAtom, {}) @@ -738,7 +746,7 @@ export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { }: { deployToEnvironment: boolean }) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const dslContent = get(dslContentAtom) const submittedInstanceName = get(instanceNameAtom).trim() const submittedReleaseName = get(releaseNameAtom).trim() diff --git a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx new file mode 100644 index 00000000000..3a99e1418a7 --- /dev/null +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SourceStepContent } from '../source-step' + +vi.mock('@/features/deployments/create-guide/state', async () => { + const { atom } = await import('jotai') + const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp') + const emptyActionAtom = atom(null, () => undefined) + + return { + continueFromSourceAtom: emptyActionAtom, + dslFileAtom: atom(undefined), + dslReadErrorAtom: atom(false), + dslUnsupportedModeAtom: atom(false), + effectiveMethodAtom: atom(get => get(methodAtom)), + effectiveSelectedAppAtom: atom(undefined), + isReadingDslAtom: atom(false), + methodAtom, + selectDslFileAtom: emptyActionAtom, + selectMethodAtom: atom(null, (_get, set, value: 'bindApp' | 'importDsl') => { + set(methodAtom, value) + }), + selectSourceAppAtom: emptyActionAtom, + setSourceSearchTextAtom: emptyActionAtom, + sourceAppsQueryAtom: atom({ + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + }), + sourceCanGoNextAtom: atom(false), + sourceSearchTextAtom: atom(''), + unsupportedDslNodesAtom: atom([]), + } +}) + +describe('SourceStepContent', () => { + it('should hide the import DSL option when deployment DSL import is disabled', () => { + render() + + expect(screen.getByText(/createGuide\.methods\.bindApp\.title/)).toBeInTheDocument() + expect(screen.queryByText(/createGuide\.methods\.importDsl\.title/)).not.toBeInTheDocument() + expect(screen.queryByText(/createGuide\.methods\.importDsl\.description/)).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ })).toBeInTheDocument() + }) +}) diff --git a/web/features/deployments/create-guide/ui/release-step.tsx b/web/features/deployments/create-guide/ui/release-step.tsx index 192351003ab..19494772935 100644 --- a/web/features/deployments/create-guide/ui/release-step.tsx +++ b/web/features/deployments/create-guide/ui/release-step.tsx @@ -7,10 +7,10 @@ import { useTranslation } from 'react-i18next' import { continueFromReleaseAtom, dslDefaultAppNameAtom, + effectiveMethodAtom, hasInstanceNameConflictAtom, instanceDescriptionAtom, instanceNameAtom, - methodAtom, releaseCanGoNextAtom, releaseDescriptionAtom, releaseNameAtom, @@ -74,7 +74,7 @@ function InstanceNameField() { const { t } = useTranslation('deployments') const instanceName = useAtomValue(instanceNameAtom) const setInstanceName = useSetAtom(setInstanceNameAtom) - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const selectedApp = useAtomValue(selectedAppAtom) const dslDefaultAppName = useAtomValue(dslDefaultAppNameAtom) const instanceNamePlaceholder = method === 'importDsl' diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index 832219f1b18..d75b9965773 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -19,9 +19,9 @@ import { dslFileAtom, dslReadErrorAtom, dslUnsupportedModeAtom, + effectiveMethodAtom, effectiveSelectedAppAtom, isReadingDslAtom, - methodAtom, selectDslFileAtom, selectMethodAtom, selectSourceAppAtom, @@ -31,12 +31,13 @@ import { sourceSearchTextAtom, unsupportedDslNodesAtom, } from '@/features/deployments/create-guide/state' +import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' import { StepShell } from './layout' const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app'] export function SourceStepContent() { - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom) return ( @@ -55,7 +56,7 @@ export function SourceStepContent() { function SourceMethodSection() { const { t } = useTranslation('deployments') - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const selectMethod = useSetAtom(selectMethodAtom) return ( @@ -76,12 +77,14 @@ function SourceMethodSection() { title={t('createGuide.methods.bindApp.title')} description={t('createGuide.methods.bindApp.description')} /> - + {isDeploymentDslImportEnabled && ( + + )} ) diff --git a/web/features/deployments/create-release/index.tsx b/web/features/deployments/create-release/index.tsx index 3bfdde51bcd..504735b8681 100644 --- a/web/features/deployments/create-release/index.tsx +++ b/web/features/deployments/create-release/index.tsx @@ -2,17 +2,21 @@ import type { ButtonProps } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button' -import { useSetAtom } from 'jotai' +import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' import { - createReleaseConfigAtom, + createReleaseAppInstanceIdAtom, + createReleaseDialogOpenAtom, createReleaseLocalAtoms, + isCreatingReleaseAtom, openCreateReleaseDialogAtom, + requestCloseCreateReleaseDialogAtom, } from './state' -import { CreateReleaseDialog } from './ui/dialog' +import { CreateReleaseDialogContent } from './ui/dialog' -function CreateReleaseTrigger({ +function CreateReleaseScopedControl({ variant, size, label, @@ -24,17 +28,39 @@ function CreateReleaseTrigger({ className?: string }) { const { t } = useTranslation('deployments') + const open = useAtomValue(createReleaseDialogOpenAtom) + const isCreatingRelease = useAtomValue(isCreatingReleaseAtom) const openDialog = useSetAtom(openCreateReleaseDialogAtom) + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) + + function handleDialogOpenChange(nextOpen: boolean) { + if (nextOpen) { + openDialog() + return + } + + if (!isCreatingRelease) + requestCloseDialog() + } return ( - + + )} + > + {label ?? t('versions.createRelease')} + + {open && } + ) } @@ -55,18 +81,17 @@ export function CreateReleaseControl({ - - ) } diff --git a/web/features/deployments/create-release/state/__tests__/index.spec.ts b/web/features/deployments/create-release/state/__tests__/index.spec.ts new file mode 100644 index 00000000000..dfb1bb12264 --- /dev/null +++ b/web/features/deployments/create-release/state/__tests__/index.spec.ts @@ -0,0 +1,490 @@ +import type { Getter } from 'jotai' +import type { CreateReleaseFormValues } from '../index' +import { QueryClient } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { consoleQuery } from '@/service/client' + +type QueryResult = { + data?: unknown + isError?: boolean + isFetching?: boolean + isLoading?: boolean + isSuccess?: boolean +} + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryFn?: () => unknown + queryKey?: readonly unknown[] + retry?: boolean +} + +type MutationResult = { + isPending: boolean + mutateAsync: ReturnType +} + +const mockQueryResults = vi.hoisted(() => ({ + current: new Map(), +})) + +const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({ + current: { + isPending: false, + mutateAsync: vi.fn(), + }, +})) + +vi.mock('jotai-tanstack-query', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { + const options = createOptions(get) + const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined + const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' + const queryResult = options.enabled === false + ? undefined + : mockQueryResults.current.get(queryName) + + return { + ...options, + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + ...queryResult, + } + }), + atomWithMutation: () => atom(() => mockCreateReleaseMutation.current), + } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + get: { + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['appById', input], + }), + }, + }, + }, + enterprise: { + releaseService: { + listReleaseSummaries: { + key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['listReleaseSummaries', input], + }), + }, + listReleases: { + key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleases'] : ['listReleases', input], + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['listReleases', input], + }), + }, + precheckRelease: { + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['precheckRelease', input], + }), + }, + createRelease: { + mutationOptions: () => ({ mutationKey: ['createRelease'] }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../index') +} + +async function mountedStore() { + const state = await loadState() + const store = createStore() + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + store.set(queryClientAtom, queryClient) + const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined) + + return { + queryClient, + state, + store, + unsubscribe, + } +} + +function sourceApp(overrides: Partial> = {}): NonNullable { + return { + id: 'source-app-1', + name: 'Source App', + mode: 'workflow', + ...overrides, + } +} + +function validationIssueMessage(error: unknown) { + if (!error || typeof error !== 'object' || !('message' in error)) + return undefined + + return typeof error.message === 'string' ? error.message : undefined +} + +function hasValidationIssue(errors: unknown[], message: string) { + return errors.some(error => validationIssueMessage(error) === message) +} + +function workflowDsl() { + return [ + 'app:', + ' mode: workflow', + ' name: Release source', + ].join('\n') +} + +function setDefaultSourceApp(defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' })) { + mockQueryResults.current.set('listReleases', { + data: { + releases: [ + { + sourceAppId: defaultSourceApp.id, + }, + ], + }, + isSuccess: true, + }) + mockQueryResults.current.set('appById', { + data: defaultSourceApp, + isSuccess: true, + }) +} + +function setPrecheckReleaseResult(overrides: { + canCreate?: boolean + matchedRelease?: unknown + unsupportedNodes?: Array<{ id?: string, type?: string }> +} = {}) { + mockQueryResults.current.set('precheckRelease', { + data: { + gateCommitId: 'gate-commit-1', + canCreate: true, + unsupportedNodes: [], + ...overrides, + }, + isSuccess: true, + }) +} + +function setCachedReleaseSummaries(queryClient: QueryClient, appInstanceId: string, displayNames: string[]) { + queryClient.setQueryData( + consoleQuery.enterprise.releaseService.listReleaseSummaries.key({ + type: 'query', + input: { params: { appInstanceId } }, + }), + { + releaseSummaries: displayNames.map(displayName => ({ + release: { + displayName, + }, + })), + pagination: {}, + }, + ) +} + +function setDslFileContentResult(overrides: QueryResult = {}) { + mockQueryResults.current.set('createReleaseDslFileContent', { + data: workflowDsl(), + isSuccess: true, + ...overrides, + }) +} + +describe('create release state', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQueryResults.current.clear() + mockCreateReleaseMutation.current = { + isPending: false, + mutateAsync: vi.fn(), + } + }) + + it('should keep default form values before editing', async () => { + const { state, store, unsubscribe } = await mountedStore() + + expect(store.get(state.createReleaseFormValuesAtom)).toEqual({ + dslFile: undefined, + releaseDescription: '', + releaseName: '', + releaseSourceMode: 'sourceApp', + sourceApp: undefined, + }) + + unsubscribe() + }) + + it('should validate release name only when submitting', async () => { + const { state, store, unsubscribe } = await mountedStore() + + await store.set(state.submitCreateReleaseFormAtom) + + expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled() + expect(hasValidationIssue( + store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [], + state.RELEASE_NAME_REQUIRED_ERROR, + )).toBe(true) + + unsubscribe() + }) + + it('should submit after fixing release name following a submit validation error', async () => { + const { state, store, unsubscribe } = await mountedStore() + const response = { + release: { + displayName: 'Release 1', + }, + } + mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + + await store.set(state.submitCreateReleaseFormAtom) + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + const result = await store.set(state.submitCreateReleaseFormAtom) + + expect(result).toBe(response) + expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should coerce DSL source mode to source app mode when DSL import is disabled', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.selectCreateReleaseSourceModeAtom, 'dsl') + + expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp') + expect(store.get(state.createReleaseSourceModeAtom)).toBe('sourceApp') + + unsubscribe() + }) + + it('should derive default source app selection from the latest release source', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + + expect(store.get(state.createReleaseSelectedSourceAppAtom)).toEqual({ + id: 'default-source-app', + name: 'Default Source App', + mode: 'workflow', + }) + expect(store.get(state.createReleaseSelectedSourceAppAtom)?.id).toBe('default-source-app') + + unsubscribe() + }) + + it('should derive workflow DSL read state when selecting a DSL file', async () => { + const { state, store, unsubscribe } = await mountedStore() + const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' }) + + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult() + + expect(store.get(state.createReleaseDslFileFieldAtom).value).toBe(file) + expect(store.get(state.createReleaseDslContentAtom)).toBe(workflowDsl()) + expect(store.get(state.createReleaseHasDslContentAtom)).toBe(true) + expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false) + expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(true) + expect(store.get(state.createReleaseEncodedDslContentAtom)).not.toBe('') + + unsubscribe() + }) + + it('should reset DSL state when switching back to source app mode', async () => { + const { state, store, unsubscribe } = await mountedStore() + const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' }) + + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult() + store.set(state.selectCreateReleaseSourceModeAtom, 'sourceApp') + + expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp') + expect(store.get(state.createReleaseDslFileFieldAtom).value).toBeUndefined() + expect(store.get(state.createReleaseDslContentAtom)).toBe('') + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false) + expect(store.get(state.createReleaseEncodedDslContentAtom)).toBe('') + expect(store.get(state.createReleaseHasDslContentAtom)).toBe(false) + expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false) + expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(false) + + unsubscribe() + }) + + it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => { + const { state, store, unsubscribe } = await mountedStore() + const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' }) + + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult({ + data: undefined, + isError: true, + isSuccess: false, + }) + + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(true) + + store.set(state.openCreateReleaseDialogAtom) + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(true) + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false) + + store.set(state.closeCreateReleaseDialogAtom) + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false) + + unsubscribe() + }) + + it('should derive content readiness from release content precheck', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + + expect(store.get(state.createReleaseContentReadyAtom)).toBe(true) + + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + expect(store.get(state.createReleaseContentReadyAtom)).toBe(true) + + unsubscribe() + }) + + it('should detect existing release name conflicts from cached release summaries', async () => { + const { queryClient, state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + store.set(state.createReleaseNameFieldAtom, ' Release 1 ') + setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1']) + + expect(store.get(state.createReleaseHasNameConflictAtom)).toBe(true) + + unsubscribe() + }) + + it('should close the dialog through the close request action', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.openCreateReleaseDialogAtom) + store.set(state.requestCloseCreateReleaseDialogAtom) + + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false) + + unsubscribe() + }) + + it('should expose unsupported nodes from release content precheck', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult({ + canCreate: false, + unsupportedNodes: [{ id: 'precheck-node' }], + }) + + expect(store.get(state.createReleaseUnsupportedDslNodesAtom)).toEqual([{ id: 'precheck-node' }]) + + unsubscribe() + }) + + it('should submit source app release with the checked source and metadata', async () => { + const { state, store, unsubscribe } = await mountedStore() + const response = { + release: { + displayName: 'Release 1', + }, + } + mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + store.set(state.createReleaseNameFieldAtom, ' Release 1 ') + store.set(state.createReleaseDescriptionFieldAtom, ' Initial rollout ') + + const result = await store.set(state.submitCreateReleaseFormAtom) + + expect(result).toBe(response) + expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledWith({ + body: { + appInstanceId: 'app-instance-1', + sourceAppId: 'default-source-app', + displayName: 'Release 1', + description: 'Initial rollout', + createAppInstance: false, + }, + }) + + unsubscribe() + }) + + it('should block release submission when release name already exists', async () => { + const { queryClient, state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1']) + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + const result = await store.set(state.submitCreateReleaseFormAtom) + + expect(result).toBeUndefined() + expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('should propagate create release submission errors', async () => { + const { state, store, unsubscribe } = await mountedStore() + const submitError = new Error('submit failed') + mockCreateReleaseMutation.current.mutateAsync.mockRejectedValue(submitError) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + await expect(store.set(state.submitCreateReleaseFormAtom)).rejects.toThrow(submitError) + + unsubscribe() + }) +}) diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts index ee6bcf9d28b..34570305926 100644 --- a/web/features/deployments/create-release/state/index.ts +++ b/web/features/deployments/create-release/state/index.ts @@ -1,122 +1,510 @@ 'use client' +import type { + CreateReleaseResponse, + ListReleasesResponse, + ListReleaseSummariesResponse, +} from '@dify/contracts/enterprise/types.gen' +import type { Getter } from 'jotai/vanilla' import type { UnsupportedDslNode } from '../../shared/domain/error' -import type { CreateReleaseForm } from './use-create-release-form' -import { atom, useAtomValue } from 'jotai' +import type { App } from '@/types/app' +import { atom } from 'jotai' +import { + atomWithForm, + createFormAtoms, +} from 'jotai-tanstack-form' +import { + atomWithMutation, + atomWithQuery, + queryClientAtom, +} from 'jotai-tanstack-query' +import * as z from 'zod' +import { consoleQuery } from '@/service/client' +import { AppModeEnum } from '@/types/app' import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl' +import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags' -type CreateReleaseConfig = { - appInstanceId: string +export type ReleaseSourceMode = 'sourceApp' | 'dsl' + +export type SourceAppPickerValue = Pick & Partial> + +export type CreateReleaseFormValues = { + releaseSourceMode: ReleaseSourceMode + sourceApp?: SourceAppPickerValue + dslFile?: File + releaseName: string + releaseDescription: string } -export type CreateReleaseDslState = { - dslContent: string - dslReadError: boolean - encodedDslContent: string - hasDslContent: boolean - isReadingDsl: boolean - isWorkflowDslContent: boolean +const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = { + releaseSourceMode: 'sourceApp', + sourceApp: undefined, + dslFile: undefined, + releaseName: '', + releaseDescription: '', } -export const createReleaseConfigAtom = atom(undefined) -export const createReleaseDialogOpenAtom = atom(false) -export const createReleaseFormAtom = atom(undefined) -export const createReleaseSubmitUnsupportedDslNodesAtom = atom([]) +export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired' -const createReleaseDslContentAtom = atom('') -const createReleaseDslReadErrorAtom = atom(false) -const createReleaseDslReadingAtom = atom(false) -const createReleaseDslReadTokenAtom = atom(0) +const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 -export const createReleaseLocalAtoms = [ - createReleaseDialogOpenAtom, - createReleaseDslContentAtom, - createReleaseDslReadErrorAtom, - createReleaseDslReadingAtom, - createReleaseDslReadTokenAtom, - createReleaseSubmitUnsupportedDslNodesAtom, -] as const +function deploymentReleaseSourceMode(mode: ReleaseSourceMode): ReleaseSourceMode { + return mode === 'dsl' && !isDeploymentDslImportEnabled + ? 'sourceApp' + : mode +} -export const clearCreateReleaseSubmissionErrorAtom = atom(null, (_get, set) => { - set(createReleaseSubmitUnsupportedDslNodesAtom, []) +function workflowSourceAppPickerValue(value: unknown, fallbackId: string): SourceAppPickerValue | undefined { + if (!value || typeof value !== 'object') + return undefined + + const record = value as Record + const mode = typeof record.mode === 'string' ? record.mode : undefined + if (mode !== AppModeEnum.WORKFLOW) + return undefined + + const id = typeof record.id === 'string' && record.id ? record.id : fallbackId + const name = typeof record.name === 'string' && record.name ? record.name : id + + return { + id, + name, + mode, + } +} + +const createReleaseFormSchema = z.object({ + releaseSourceMode: z.union([z.literal('sourceApp'), z.literal('dsl')]), + sourceApp: z.custom().optional(), + dslFile: z.custom().optional(), + releaseName: z.string().trim().min(1, RELEASE_NAME_REQUIRED_ERROR), + releaseDescription: z.string(), }) -export const resetCreateReleaseDslFileAtom = atom(null, (get, set) => { - set(createReleaseDslReadTokenAtom, get(createReleaseDslReadTokenAtom) + 1) - set(createReleaseDslContentAtom, '') - set(createReleaseDslReadingAtom, false) - set(createReleaseDslReadErrorAtom, false) +type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise | CreateReleaseResponse | undefined + +type CreateReleaseSubmitMeta = { + createRelease: CreateReleaseSubmit +} + +const noopCreateRelease: CreateReleaseSubmit = () => undefined + +// Form state +export const createReleaseFormAtom = atomWithForm({ + defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES, + onSubmitMeta: { + createRelease: noopCreateRelease, + }, + validators: { + onSubmit: createReleaseFormSchema, + }, + onSubmit: ({ value, meta }) => meta.createRelease(value), +}) + +const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom) + +export const createReleaseFormValuesAtom = createReleaseFormAtoms.valuesAtom +export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom +export const createReleaseSourceModeFieldAtom = createReleaseFormAtoms.fieldAtom('releaseSourceMode') +export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp') +export const createReleaseDslFileFieldAtom = createReleaseFormAtoms.fieldAtom('dslFile') +export const createReleaseNameFieldAtom = createReleaseFormAtoms.fieldAtom('releaseName') +export const createReleaseDescriptionFieldAtom = createReleaseFormAtoms.fieldAtom('releaseDescription') + +// Dialog and source primitives +export const createReleaseAppInstanceIdAtom = atom(undefined) +export const createReleaseDialogOpenAtom = atom(false) +const createReleaseDslFileReadVersionAtom = atom(0) + +function requiredAppInstanceId(get: Getter) { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + if (!appInstanceId) + throw new Error('Missing create release app instance id.') + + return appInstanceId +} + +// Query and remote data +const latestSourceReleaseQueryAtom = atomWithQuery((get) => { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + + return consoleQuery.enterprise.releaseService.listReleases.queryOptions({ + input: { + params: { appInstanceId: appInstanceId ?? '' }, + query: { + pageNumber: 1, + resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE, + }, + }, + enabled: Boolean(appInstanceId && get(createReleaseDialogOpenAtom)), + }) +}) + +function latestReleaseSourceAppId(get: Getter) { + const latestReleaseQuery = get(latestSourceReleaseQueryAtom) + + return latestReleaseQuery.data?.releases[0]?.sourceAppId +} + +const defaultSourceAppQueryAtom = atomWithQuery((get) => { + const latestSourceAppId = latestReleaseSourceAppId(get) + + return consoleQuery.apps.byAppId.get.queryOptions({ + input: { + params: { app_id: latestSourceAppId ?? '' }, + }, + enabled: Boolean(get(createReleaseDialogOpenAtom) && latestSourceAppId), + }) +}) + +function defaultSourceApp(get: Getter) { + const latestSourceAppId = latestReleaseSourceAppId(get) + if (!latestSourceAppId) + return undefined + + return workflowSourceAppPickerValue(get(defaultSourceAppQueryAtom).data, latestSourceAppId) +} + +function submittedReleaseName(get: Getter) { + return get(createReleaseNameFieldAtom).value.trim() +} + +function cachedReleaseDisplayNames(get: Getter) { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + if (!appInstanceId) + return [] + + const queryClient = get(queryClientAtom) + const releaseSummaryQueries = queryClient.getQueriesData({ + queryKey: consoleQuery.enterprise.releaseService.listReleaseSummaries.key({ + type: 'query', + input: { params: { appInstanceId } }, + }), + }) + const releaseQueries = queryClient.getQueriesData({ + queryKey: consoleQuery.enterprise.releaseService.listReleases.key({ + type: 'query', + input: { params: { appInstanceId } }, + }), + }) + + return [ + ...releaseSummaryQueries.flatMap(([, data]) => { + return data?.releaseSummaries.map(summary => summary.release.displayName) ?? [] + }), + ...releaseQueries.flatMap(([, data]) => { + return data?.releases.map(release => release.displayName) ?? [] + }), + ] +} + +export const createReleaseHasNameConflictAtom = atom((get) => { + const releaseName = submittedReleaseName(get) + if (!releaseName) + return false + + return cachedReleaseDisplayNames(get).some(displayName => displayName.trim() === releaseName) +}) + +const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => { + const file = get(createReleaseDslFileFieldAtom).value + const fileReadVersion = get(createReleaseDslFileReadVersionAtom) + + return { + queryKey: [ + 'createReleaseDslFileContent', + fileReadVersion, + file, + file?.name ?? '', + file?.size ?? 0, + file?.lastModified ?? 0, + ], + queryFn: async () => file ? await file.text() : '', + enabled: Boolean(file), + retry: false, + } +}) + +// Source derived state +function effectiveCreateReleaseSourceMode(get: Getter) { + return deploymentReleaseSourceMode(get(createReleaseSourceModeFieldAtom).value) +} + +export const createReleaseSourceModeAtom = atom((get) => { + return effectiveCreateReleaseSourceMode(get) +}) + +export const createReleaseDslContentAtom = atom((get) => { + return get(createReleaseDslFileContentQueryAtom).data ?? '' +}) + +export const createReleaseDslReadErrorAtom = atom((get) => { + return Boolean(get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError) +}) + +export const isReadingCreateReleaseDslAtom = atom((get) => { + const file = get(createReleaseDslFileFieldAtom).value + const dslFileContentQuery = get(createReleaseDslFileContentQueryAtom) + + return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching)) +}) + +export const createReleaseHasDslContentAtom = atom((get) => { + return Boolean(get(createReleaseDslContentAtom).trim()) +}) + +export const createReleaseIsWorkflowDslContentAtom = atom((get) => { + const dslContent = get(createReleaseDslContentAtom) + + return get(createReleaseHasDslContentAtom) ? isWorkflowDsl(dslContent) : false +}) + +export const createReleaseEncodedDslContentAtom = atom((get) => { + const dslContent = get(createReleaseDslContentAtom) + + return get(createReleaseHasDslContentAtom) && get(createReleaseIsWorkflowDslContentAtom) + ? encodeDslContent(dslContent) + : '' +}) + +export const createReleaseSelectedSourceAppAtom = atom((get) => { + if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp') + return undefined + + const fieldSourceApp = get(createReleaseSourceAppFieldAtom).value + const fallbackSourceApp = defaultSourceApp(get) + + if (!isDeploymentDslImportEnabled) + return fallbackSourceApp + + return fieldSourceApp ?? fallbackSourceApp +}) + +function selectedSourceAppId(get: Getter) { + return effectiveCreateReleaseSourceMode(get) === 'sourceApp' + ? get(createReleaseSelectedSourceAppAtom)?.id + : undefined +} + +function hasUnsupportedDslMode(get: Getter) { + if (effectiveCreateReleaseSourceMode(get) !== 'dsl') + return false + + return get(createReleaseHasDslContentAtom) + && !get(isReadingCreateReleaseDslAtom) + && !get(createReleaseDslReadErrorAtom) + && !get(createReleaseIsWorkflowDslContentAtom) +} + +export const createReleaseHasUnsupportedDslModeAtom = atom((get) => { + return hasUnsupportedDslMode(get) +}) + +function canCheckReleaseSourceContent(get: Getter) { + if (effectiveCreateReleaseSourceMode(get) === 'sourceApp') + return Boolean(selectedSourceAppId(get)) + if (!isDeploymentDslImportEnabled) + return false + + return Boolean( + get(createReleaseHasDslContentAtom) + && !get(isReadingCreateReleaseDslAtom) + && !get(createReleaseDslReadErrorAtom) + && !hasUnsupportedDslMode(get), + ) +} + +function canCheckReleaseContent(get: Getter) { + return Boolean( + get(createReleaseAppInstanceIdAtom) + && get(createReleaseDialogOpenAtom) + && canCheckReleaseSourceContent(get), + ) +} + +// Release content check +const precheckReleaseQueryAtom = atomWithQuery((get) => { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + const releaseSourceMode = effectiveCreateReleaseSourceMode(get) + const sourceAppId = selectedSourceAppId(get) + const canCheck = canCheckReleaseContent(get) + + return { + ...consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ + input: { + body: { + appInstanceId: appInstanceId ?? '', + ...(releaseSourceMode === 'dsl' + ? { dsl: get(createReleaseEncodedDslContentAtom) } + : { sourceAppId: sourceAppId ?? '' }), + }, + }, + enabled: canCheck, + }), + retry: false, + } +}) + +export const isCheckingCreateReleaseContentAtom = atom((get) => { + const canCheck = canCheckReleaseContent(get) + const precheckReleaseQuery = get(precheckReleaseQueryAtom) + + return canCheck && (precheckReleaseQuery.isLoading || precheckReleaseQuery.isFetching) +}) + +export const createReleaseMatchedReleaseAtom = atom((get) => { + return canCheckReleaseContent(get) + ? get(precheckReleaseQueryAtom).data?.matchedRelease + : undefined +}) + +export const createReleaseContentCheckFailedAtom = atom((get) => { + return canCheckReleaseContent(get) && get(precheckReleaseQueryAtom).isError +}) + +export const createReleaseUnsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => { + return canCheckReleaseContent(get) + ? get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? [] + : [] +}) + +export const createReleaseContentReadyAtom = atom((get) => { + const canCheck = canCheckReleaseContent(get) + const precheckReleaseQuery = get(precheckReleaseQueryAtom) + + return canCheck + && precheckReleaseQuery.isSuccess + && !get(isCheckingCreateReleaseContentAtom) + && !get(createReleaseContentCheckFailedAtom) + && Boolean(precheckReleaseQuery.data?.canCreate) + && get(createReleaseUnsupportedDslNodesAtom).length === 0 +}) + +// Actions +const resetCreateReleaseDslFileAtom = atom(null, (get, set) => { + set(createReleaseDslFileFieldAtom, undefined) + set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) }) export const openCreateReleaseDialogAtom = atom(null, (_get, set) => { - set(clearCreateReleaseSubmissionErrorAtom) set(resetCreateReleaseDslFileAtom) set(createReleaseDialogOpenAtom, true) }) export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => { set(createReleaseDialogOpenAtom, false) - set(clearCreateReleaseSubmissionErrorAtom) set(resetCreateReleaseDslFileAtom) }) -export const selectCreateReleaseDslFileAtom = atom(null, async (get, set, file?: File) => { - const readToken = get(createReleaseDslReadTokenAtom) + 1 - set(createReleaseDslReadTokenAtom, readToken) - set(createReleaseDslContentAtom, '') - set(createReleaseDslReadingAtom, false) - set(createReleaseDslReadErrorAtom, false) - - if (!file) +export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => { + if (get(createReleaseFormIsSubmittingAtom)) return - set(createReleaseDslReadingAtom, true) - try { - const content = await file.text() - if (get(createReleaseDslReadTokenAtom) !== readToken) - return - - set(createReleaseDslContentAtom, content) - } - catch { - if (get(createReleaseDslReadTokenAtom) !== readToken) - return - - set(createReleaseDslReadErrorAtom, true) - } - finally { - if (get(createReleaseDslReadTokenAtom) === readToken) - set(createReleaseDslReadingAtom, false) - } + set(closeCreateReleaseDialogAtom) }) -export const createReleaseDslStateAtom = atom((get): CreateReleaseDslState => { - const dslContent = get(createReleaseDslContentAtom) - const hasDslContent = Boolean(dslContent.trim()) - const isWorkflowDslContent = hasDslContent ? isWorkflowDsl(dslContent) : false +export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseSourceMode: ReleaseSourceMode) => { + const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode) + set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode) - return { - dslContent, - dslReadError: get(createReleaseDslReadErrorAtom), - encodedDslContent: hasDslContent && isWorkflowDslContent ? encodeDslContent(dslContent) : '', - hasDslContent, - isReadingDsl: get(createReleaseDslReadingAtom), - isWorkflowDslContent, + if (effectiveReleaseSourceMode === 'sourceApp') { + set(resetCreateReleaseDslFileAtom) + return } + + set(createReleaseSourceAppFieldAtom, undefined) }) -export function useCreateReleaseConfig() { - const config = useAtomValue(createReleaseConfigAtom) - if (!config) - throw new Error('Missing create release config.') +export const updateCreateReleaseSourceAppAtom = atom(null, (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => { + set(createReleaseSourceAppFieldAtom, sourceApp) +}) - return config +export const updateCreateReleaseDslFileAtom = atom(null, (get, set, dslFile: CreateReleaseFormValues['dslFile']) => { + set(createReleaseDslFileFieldAtom, dslFile) + set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) +}) + +// Submission +const createReleaseMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.releaseService.createRelease.mutationOptions(), +) + +export const isCreatingReleaseAtom = atom((get) => { + return get(createReleaseMutationAtom).isPending +}) + +export class CreateReleaseSubmissionBlockedError extends Error { + reason: 'unsupportedDslMode' + + constructor(reason: 'unsupportedDslMode') { + super(reason) + this.reason = reason + this.name = 'CreateReleaseSubmissionBlockedError' + } } -export function useCreateReleaseFormApi() { - const form = useAtomValue(createReleaseFormAtom) - if (!form) - throw new Error('Missing create release form.') +const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateReleaseFormValues) => { + const releaseSourceMode = effectiveCreateReleaseSourceMode(get) + const sourceAppId = selectedSourceAppId(get) + const submittedReleaseName = value.releaseName.trim() - return form -} + if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName) + return undefined + + if (get(createReleaseHasNameConflictAtom)) + return undefined + + if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom)) + return undefined + + const appInstanceId = requiredAppInstanceId(get) + const commonCreateReleaseRequest = { + appInstanceId, + displayName: submittedReleaseName, + description: value.releaseDescription.trim() || undefined, + createAppInstance: false, + } + + if (releaseSourceMode === 'dsl') { + if (!get(createReleaseIsWorkflowDslContentAtom)) + throw new CreateReleaseSubmissionBlockedError('unsupportedDslMode') + + return await get(createReleaseMutationAtom).mutateAsync({ + body: { + ...commonCreateReleaseRequest, + dsl: get(createReleaseEncodedDslContentAtom), + }, + }) + } + + if (!sourceAppId) + return undefined + + return await get(createReleaseMutationAtom).mutateAsync({ + body: { + ...commonCreateReleaseRequest, + sourceAppId, + }, + }) +}) + +export const submitCreateReleaseFormAtom = atom(null, (get, set) => { + const form = get(createReleaseFormAtom) + let submitResponse: CreateReleaseResponse | undefined + + return form.api.handleSubmit({ + createRelease: async (value) => { + const response = await set(createReleaseSubmissionAtom, value) + submitResponse = response + + return response + }, + } satisfies CreateReleaseSubmitMeta) + .then(() => submitResponse) +}) + +// Scoped atoms +export const createReleaseLocalAtoms = [ + createReleaseDialogOpenAtom, + createReleaseDslFileReadVersionAtom, +] as const diff --git a/web/features/deployments/create-release/state/types.ts b/web/features/deployments/create-release/state/types.ts deleted file mode 100644 index 0ebb291ed7c..00000000000 --- a/web/features/deployments/create-release/state/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SourceAppPickerValue } from '../ui/source-app-picker-value' - -export type ReleaseSourceMode = 'sourceApp' | 'dsl' - -export type CreateReleaseFormValues = { - releaseSourceMode: ReleaseSourceMode - sourceApp?: SourceAppPickerValue - dslFile?: File - releaseName: string - releaseDescription: string -} - -export const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = { - releaseSourceMode: 'sourceApp', - sourceApp: undefined, - dslFile: undefined, - releaseName: '', - releaseDescription: '', -} diff --git a/web/features/deployments/create-release/state/use-create-release-form.ts b/web/features/deployments/create-release/state/use-create-release-form.ts deleted file mode 100644 index 9e7c3b6e911..00000000000 --- a/web/features/deployments/create-release/state/use-create-release-form.ts +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import type { CreateReleaseFormValues } from './types' -import { useForm } from '@tanstack/react-form' -import { DEFAULT_CREATE_RELEASE_FORM_VALUES } from './types' - -export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired' - -export function validateReleaseName({ value }: { - value: string -}) { - return value.trim() ? undefined : RELEASE_NAME_REQUIRED_ERROR -} - -export function useCreateReleaseForm({ onSubmit }: { - onSubmit: (value: CreateReleaseFormValues) => Promise | void -}) { - return useForm({ - defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES, - onSubmit: ({ value }) => onSubmit(value), - }) -} - -export type CreateReleaseForm = ReturnType diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx new file mode 100644 index 00000000000..dbf553b8d1f --- /dev/null +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -0,0 +1,33 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SourceAppPicker } from '../source-app-picker' + +function renderSourceAppPicker(disabled: boolean) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + undefined} + disabled={disabled} + /> + , + ) +} + +describe('SourceAppPicker', () => { + it('should disable the switch control when disabled', () => { + renderSourceAppPicker(true) + + expect(screen.getByText('Workflow 1')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled() + }) +}) diff --git a/web/features/deployments/create-release/ui/actions.tsx b/web/features/deployments/create-release/ui/actions.tsx index cd2eee506ef..40558eedefe 100644 --- a/web/features/deployments/create-release/ui/actions.tsx +++ b/web/features/deployments/create-release/ui/actions.tsx @@ -1,61 +1,26 @@ 'use client' -import type { CreateReleaseFormValues } from '../state/types' import { Button } from '@langgenius/dify-ui/button' -import { useSetAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { - closeCreateReleaseDialogAtom, - useCreateReleaseFormApi, + createReleaseContentReadyAtom, + createReleaseFormIsSubmittingAtom, + createReleaseHasNameConflictAtom, + createReleaseNameFieldAtom, + isCheckingCreateReleaseContentAtom, + requestCloseCreateReleaseDialogAtom, } from '../state' -import { - createReleaseReadiness, - useCreateReleaseSourceSelection, - useReleaseContentCheck, -} from './use-release-content-check' export function CreateReleaseActions() { - const form = useCreateReleaseFormApi() - - return ( - ({ - isSubmitting: state.isSubmitting, - values: state.values, - })} - > - {({ isSubmitting, values }) => ( - - )} - - ) -} - -function CreateReleaseActionsContent({ - formValues, - isSubmitting, -}: { - formValues: CreateReleaseFormValues - isSubmitting: boolean -}) { const { t } = useTranslation('deployments') - const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) - const sourceSelection = useCreateReleaseSourceSelection(formValues) - const releaseContent = useReleaseContentCheck(sourceSelection) - const { canCreate, isCheckingReleaseContent } = createReleaseReadiness({ - formValues, - isSubmitting, - releaseContent, - }) - - function requestClose() { - if (isSubmitting) - return - - closeDialog() - } + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) + const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom) + const releaseContentReady = useAtomValue(createReleaseContentReadyAtom) + const isCheckingReleaseContent = useAtomValue(isCheckingCreateReleaseContentAtom) + const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom) + const releaseNameField = useAtomValue(createReleaseNameFieldAtom) + const hasReleaseName = Boolean(releaseNameField.value.trim()) return (

@@ -67,12 +32,12 @@ function CreateReleaseActionsContent({ onPointerDown={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} onClick={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} > {t('versions.cancelCreate')} @@ -81,7 +46,8 @@ function CreateReleaseActionsContent({ type="submit" variant="primary" className="min-w-22" - disabled={!canCreate} + disabled={!hasReleaseName || !releaseContentReady || hasReleaseNameConflict} + loading={isSubmitting} > {isSubmitting ? t('versions.creating') : isCheckingReleaseContent ? t('versions.checkingReleaseContent') : t('versions.create')} diff --git a/web/features/deployments/create-release/ui/content-feedback.tsx b/web/features/deployments/create-release/ui/content-feedback.tsx index 24e4a573744..8e7b2e323f4 100644 --- a/web/features/deployments/create-release/ui/content-feedback.tsx +++ b/web/features/deployments/create-release/ui/content-feedback.tsx @@ -1,59 +1,31 @@ 'use client' -import type { CreateReleaseFormValues } from '../state/types' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert' import { - createReleaseSubmitUnsupportedDslNodesAtom, - useCreateReleaseFormApi, + createReleaseContentCheckFailedAtom, + createReleaseMatchedReleaseAtom, + createReleaseUnsupportedDslNodesAtom, } from '../state' -import { - useCreateReleaseSourceSelection, - useReleaseContentCheck, -} from './use-release-content-check' export function ReleaseContentFeedback() { - const form = useCreateReleaseFormApi() - - return ( - state.values}> - {formValues => } - - ) -} - -function ReleaseContentFeedbackContent({ formValues }: { - formValues: CreateReleaseFormValues -}) { const { t } = useTranslation('deployments') - const sourceSelection = useCreateReleaseSourceSelection(formValues) - const releaseContent = useReleaseContentCheck(sourceSelection) - const submitUnsupportedDslNodes = useAtomValue(createReleaseSubmitUnsupportedDslNodesAtom) - // Precheck reports unsupported nodes at pick time; the post-submit atom stays - // as the TOCTOU fallback when the content changes server-side between - // precheck and create. - const unsupportedDslNodes = releaseContent.unsupportedNodes.length > 0 - ? releaseContent.unsupportedNodes - : submitUnsupportedDslNodes + const unsupportedDslNodes = useAtomValue(createReleaseUnsupportedDslNodesAtom) + const matchedRelease = useAtomValue(createReleaseMatchedReleaseAtom) + const releaseContentCheckFailed = useAtomValue(createReleaseContentCheckFailedAtom) return ( <> - {releaseContent.isCheckingReleaseContent && ( -
- {t('versions.checkingReleaseContent')} -
- )} - - {releaseContent.matchedRelease && ( + {matchedRelease && (
- {t('versions.releaseAlreadyExists', { name: releaseContent.matchedRelease.displayName })} + {t('versions.releaseAlreadyExists', { name: matchedRelease.displayName })}
)} - {releaseContent.releaseContentCheckFailed && ( + {releaseContentCheckFailed && (
{t('versions.releaseContentCheckFailed')}
diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx index 501600e73ac..a0b6dde2fc6 100644 --- a/web/features/deployments/create-release/ui/dialog.tsx +++ b/web/features/deployments/create-release/ui/dialog.tsx @@ -1,42 +1,27 @@ 'use client' -import type { CreateReleaseFormValues } from '../state/types' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' -import { skipToken, useQuery } from '@tanstack/react-query' +import { DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' -import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' +import { deploymentErrorMessage } from '../../shared/domain/error' import { closeCreateReleaseDialogAtom, - createReleaseDialogOpenAtom, createReleaseFormAtom, - openCreateReleaseDialogAtom, - useCreateReleaseConfig, - useCreateReleaseFormApi, + createReleaseFormIsSubmittingAtom, + CreateReleaseSubmissionBlockedError, + requestCloseCreateReleaseDialogAtom, + submitCreateReleaseFormAtom, } from '../state' -import { useCreateReleaseForm } from '../state/use-create-release-form' import { CreateReleaseActions } from './actions' import { ReleaseContentFeedback } from './content-feedback' import { ReleaseMetadataFields } from './metadata-fields' -import { workflowSourceAppPickerValue } from './source-app-picker-value' import { ReleaseSourceSection } from './source-section' -import { useCreateReleaseSubmission } from './use-create-release-submission' -const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 - -function CreateReleaseCloseButton({ isSubmitting }: { - isSubmitting: boolean -}) { - const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) - - function requestClose() { - if (isSubmitting) - return - - closeDialog() - } +function CreateReleaseCloseButton() { + const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom) + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) return ( { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} onClick={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} /> ) } -function CreateReleaseDefaultSourceApp({ formValues }: { - formValues: CreateReleaseFormValues -}) { - const { appInstanceId } = useCreateReleaseConfig() - const form = useCreateReleaseFormApi() - const isDialogOpen = useAtomValue(createReleaseDialogOpenAtom) - const latestReleaseQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({ - input: { - params: { appInstanceId }, - query: { - pageNumber: 1, - resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE, - }, - }, - enabled: isDialogOpen, - })) - const latestSourceAppId = latestReleaseQuery.data?.releases[0]?.sourceAppId - const defaultSourceAppInput = isDialogOpen && latestSourceAppId - ? { params: { app_id: latestSourceAppId } } - : undefined - const defaultSourceAppQuery = useQuery(defaultSourceAppInput - ? consoleQuery.apps.byAppId.get.queryOptions({ - input: defaultSourceAppInput, - }) - : { - queryFn: skipToken, - queryKey: ['create-release', 'default-source-app'], - }) - const defaultSourceApp = latestSourceAppId - ? workflowSourceAppPickerValue(defaultSourceAppQuery.data, latestSourceAppId) - : undefined - - useEffect(() => { - if (!isDialogOpen || formValues.releaseSourceMode !== 'sourceApp' || formValues.sourceApp || !defaultSourceApp) - return - - form.setFieldValue('sourceApp', defaultSourceApp) - }, [defaultSourceApp, form, formValues.releaseSourceMode, formValues.sourceApp, isDialogOpen]) - - return null -} - -function CreateReleaseDialogForm() { - const submitReleaseRef = useRef<(value: CreateReleaseFormValues) => Promise | void>(() => undefined) - const form = useCreateReleaseForm({ - onSubmit: value => submitReleaseRef.current(value), - }) - +export function CreateReleaseDialogContent() { return ( - - ({ - isSubmitting: state.isSubmitting, - values: state.values, - })} - > - {({ isSubmitting, values }) => ( - - )} - + + ) } -function CreateReleaseDialogSurface({ - formValues, - isSubmitting, - submitReleaseRef, -}: { - formValues: CreateReleaseFormValues - isSubmitting: boolean - submitReleaseRef: { - current: (value: CreateReleaseFormValues) => Promise | void - } -}) { - const open = useAtomValue(createReleaseDialogOpenAtom) - const openDialog = useSetAtom(openCreateReleaseDialogAtom) +function CreateReleaseDialogSurface() { const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) + const submitCreateReleaseForm = useSetAtom(submitCreateReleaseFormAtom) const { t } = useTranslation('deployments') - const form = useCreateReleaseFormApi() - const submission = useCreateReleaseSubmission(formValues) - submitReleaseRef.current = submission.createRelease - function handleDialogOpenChange(nextOpen: boolean) { - if (nextOpen) { - openDialog() - return - } + async function handleSubmit() { + try { + const response = await submitCreateReleaseForm() + if (!response) + return - if (!isSubmitting) + toast.success(t('versions.createSuccess', { name: response.release.displayName })) closeDialog() + } + catch (error) { + if (error instanceof CreateReleaseSubmissionBlockedError) { + toast.error(t('versions.dslUnsupportedMode')) + return + } + + const message = await deploymentErrorMessage(error) + toast.error(message || t('versions.createFailed')) + } } return ( - - - - -
{ - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() - }} - > -
-
- - {t('versions.createRelease')} - - - {t('versions.createReleaseDescription')} - -
+ + + { + event.preventDefault() + event.stopPropagation() + void handleSubmit() + }} + > +
+
+ + {t('versions.createRelease')} + + + {t('versions.createReleaseDescription')} +
+
-
- - - -
+
+ + + +
- - -
-
+ + + ) } - -export function CreateReleaseDialog() { - const open = useAtomValue(createReleaseDialogOpenAtom) - - if (!open) - return null - - return -} diff --git a/web/features/deployments/create-release/ui/metadata-fields.tsx b/web/features/deployments/create-release/ui/metadata-fields.tsx index ee82c0bf18f..0144fae1ec3 100644 --- a/web/features/deployments/create-release/ui/metadata-fields.tsx +++ b/web/features/deployments/create-release/ui/metadata-fields.tsx @@ -3,25 +3,53 @@ import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' +import { useAtom, useAtomValue } from 'jotai' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useCreateReleaseFormApi } from '../state' import { + createReleaseDescriptionFieldAtom, + createReleaseHasNameConflictAtom, + createReleaseNameFieldAtom, RELEASE_NAME_REQUIRED_ERROR, - validateReleaseName, -} from '../state/use-create-release-form' +} from '../state' const DESCRIPTION_MAX_LENGTH = 512 const DESCRIPTION_WARN_THRESHOLD = 460 +function isValidationIssue(error: unknown): error is { message: string } { + return Boolean( + error + && typeof error === 'object' + && 'message' in error + && typeof error.message === 'string', + ) +} + function hasReleaseNameRequiredError(errors: unknown[]) { - return errors.includes(RELEASE_NAME_REQUIRED_ERROR) + return errors.some((error) => { + if (error === RELEASE_NAME_REQUIRED_ERROR) + return true + + if (Array.isArray(error)) + return error.some(issue => isValidationIssue(issue) && issue.message === RELEASE_NAME_REQUIRED_ERROR) + + return isValidationIssue(error) && error.message === RELEASE_NAME_REQUIRED_ERROR + }) } export function ReleaseMetadataFields() { const { t } = useTranslation('deployments') - const form = useCreateReleaseFormApi() + const [releaseNameField, setReleaseNameField] = useAtom(createReleaseNameFieldAtom) + const [releaseDescriptionField, setReleaseDescriptionField] = useAtom(createReleaseDescriptionFieldAtom) + const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom) const releaseNameInputRef = useRef(null) + const releaseNameErrors = releaseNameField.meta?.errors ?? [] + const hasReleaseNameRequired = hasReleaseNameRequiredError(releaseNameErrors) + const releaseNameError = hasReleaseNameRequired + ? t('versions.releaseNameRequired') + : hasReleaseNameConflict + ? t('versions.releaseNameConflict') + : '' useEffect(() => { releaseNameInputRef.current?.focus() @@ -29,78 +57,66 @@ export function ReleaseMetadataFields() { return ( <> - - {field => ( -
- - field.handleChange(event.target.value)} - className="h-9" - /> - {hasReleaseNameRequiredError(field.state.meta.errors) && ( - - )} +
+ + { + setReleaseNameField(event.target.value) + }} + className="h-9" + /> + {releaseNameError && ( + )} - +
- - {field => ( -
-
- -
- - {t('versions.optional')} - - = DESCRIPTION_WARN_THRESHOLD ? 'text-util-colors-warning-warning-700' : 'text-text-quaternary', - )} - > - {field.state.value.length} - / - {DESCRIPTION_MAX_LENGTH} - -
-
-