Compare commits
26 Commits
main
...
hotfix/1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d24f2863d | ||
|
|
0188ab7121 | ||
|
|
5bbd5feacf | ||
|
|
d080f1f0c8 | ||
|
|
04ea282b98 | ||
|
|
924ca907c2 | ||
|
|
21c54505d1 | ||
|
|
6ac5d49672 | ||
|
|
b838c4b34e | ||
|
|
dd89355ad5 | ||
|
|
5e9c15a120 | ||
|
|
8adbd39799 | ||
|
|
3991538c2a | ||
|
|
91cfe7e389 | ||
|
|
c5c32e657d | ||
|
|
3c4faa5173 | ||
|
|
4919a9a7ef | ||
|
|
04ba11dede | ||
|
|
71a2e849fa | ||
|
|
159e384af4 | ||
|
|
ae3503ce11 | ||
|
|
f4e2c1b610 | ||
|
|
ba5d8f1855 | ||
|
|
59516de8a3 | ||
|
|
41e52b2130 | ||
|
|
dfbb81140c |
@@ -8,6 +8,7 @@ from constants.languages import languages
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, password_pattern, valid_password
|
||||
from services.account_email import normalize_email
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
|
||||
|
||||
@@ -82,6 +83,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
with Session(db.engine) as session:
|
||||
account = session.merge(account)
|
||||
account.email = normalized_new_email
|
||||
account.normalized_email = normalize_email(normalized_new_email)
|
||||
session.commit()
|
||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AgentSessionConfigurationChangedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -620,6 +621,10 @@ def _raise_agent_stream_error_before_response(response):
|
||||
if isinstance(response, _ClosableStream):
|
||||
response.close()
|
||||
message = error_payload.get("message")
|
||||
if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code:
|
||||
raise AgentSessionConfigurationChangedError(
|
||||
str(message or AgentSessionConfigurationChangedError.description)
|
||||
)
|
||||
raise CompletionRequestError(str(message or "Agent App chat failed."))
|
||||
|
||||
return _prepend_stream_chunks(buffered, chunk, iterator)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE,
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE,
|
||||
)
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
@@ -49,6 +53,12 @@ class CompletionRequestError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentSessionConfigurationChangedError(BaseHTTPException):
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
description = AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE
|
||||
code = 409
|
||||
|
||||
|
||||
class AppMoreLikeThisDisabledError(BaseHTTPException):
|
||||
error_code = "app_more_like_this_disabled"
|
||||
description = "The 'More like this' feature is disabled. Please refresh your page."
|
||||
|
||||
@@ -13,6 +13,7 @@ from controllers.console.auth.error import (
|
||||
EmailRegisterLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
@@ -25,6 +26,7 @@ from models import Account
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
@@ -221,11 +223,14 @@ class EmailRegisterResetApi(Resource):
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=db.session(),
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountNormalizedEmailAlreadyInUseError as exc:
|
||||
raise NormalizedEmailAlreadyInUseError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
@@ -175,6 +175,11 @@ class EmailAlreadyInUseError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class NormalizedEmailAlreadyInUseError(EmailAlreadyInUseError):
|
||||
error_code = "normalized_email_already_in_use"
|
||||
description = "An account with an equivalent email address already exists."
|
||||
|
||||
|
||||
class OwnerTransferLimitError(BaseHTTPException):
|
||||
error_code = "owner_transfer_limit"
|
||||
description = "Too many failed owner transfer attempts. Please try again in 24 hours."
|
||||
|
||||
@@ -27,6 +27,7 @@ from controllers.console.auth.error import (
|
||||
EmailPasswordLoginLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
TurnstileServiceUnavailableError,
|
||||
TurnstileVerificationFailedError,
|
||||
)
|
||||
@@ -70,6 +71,7 @@ from services.email_code_login_challenge import (
|
||||
)
|
||||
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
RefreshTokenAccountNotFoundError,
|
||||
RefreshTokenNotFoundError,
|
||||
@@ -412,6 +414,7 @@ class EmailCodeLoginApi(Resource):
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=req_data.timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=db.session(),
|
||||
)
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
@@ -421,6 +424,8 @@ class EmailCodeLoginApi(Resource):
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountNormalizedEmailAlreadyInUseError as exc:
|
||||
raise NormalizedEmailAlreadyInUseError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
|
||||
raise AccountInFreezeError() from exc
|
||||
|
||||
@@ -210,8 +210,6 @@ class DefaultModelApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@model_validate(ParserGetDefault)
|
||||
@@ -265,8 +263,6 @@ class ModelProviderModelApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider: str):
|
||||
@@ -353,8 +349,6 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
|
||||
@@ -661,7 +661,8 @@ def model_validate[T, M: BaseModel, **P, R](
|
||||
"""Validate request data and inject the model instance as the first arg after self.
|
||||
|
||||
Source is determined by HTTP method:
|
||||
GET/DELETE -> request.args
|
||||
GET -> request.args
|
||||
DELETE -> request.args, falling back to JSON body when the query string is empty
|
||||
POST/PUT/PATCH -> JSON body
|
||||
"""
|
||||
|
||||
@@ -670,8 +671,10 @@ def model_validate[T, M: BaseModel, **P, R](
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
@wraps(view)
|
||||
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if request.method in ("GET", "DELETE"):
|
||||
if request.method == "GET":
|
||||
raw = request.args.to_dict(flat=True)
|
||||
elif request.method == "DELETE":
|
||||
raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {})
|
||||
else:
|
||||
raw = request.get_json(silent=True) or {}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
||||
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
|
||||
@@ -35,6 +35,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
@@ -57,6 +58,9 @@ from services.errors.app import (
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -70,6 +74,8 @@ def _translate_service_errors() -> Generator[None, None, None]:
|
||||
raise NotFound(str(ex))
|
||||
except (IsDraftWorkflowError, WorkflowIdFormatError) as ex:
|
||||
raise BadRequest(str(ex))
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@@ -37,6 +41,12 @@ class WorkflowVersionExecutionNotAllowedError(BaseHTTPException):
|
||||
code = 403
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@@ -28,6 +28,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.schema import (
|
||||
@@ -60,7 +61,14 @@ from models.model import App, AppMode, EndUser
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
IsDraftWorkflowError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@@ -295,6 +303,11 @@ class WorkflowRunApi(Resource):
|
||||
"- `completion_request_error` : Workflow execution request failed.\n"
|
||||
"- `invalid_param` : Invalid parameter value."
|
||||
),
|
||||
403: (
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through "
|
||||
"Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
429: (
|
||||
"- `too_many_requests` : Too many concurrent requests for this app.\n"
|
||||
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
|
||||
@@ -355,6 +368,8 @@ class WorkflowRunApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@@ -401,8 +416,11 @@ class WorkflowRunByIdApi(Resource):
|
||||
"- `invalid_param` : Required parameter missing or invalid."
|
||||
),
|
||||
403: (
|
||||
"`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan."
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry "
|
||||
"and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
404: "`not_found` : Workflow not found.",
|
||||
429: (
|
||||
@@ -482,6 +500,8 @@ class WorkflowRunByIdApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except WorkflowNotFoundError as ex:
|
||||
raise NotFound(str(ex))
|
||||
except IsDraftWorkflowError as ex:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@@ -31,6 +35,12 @@ class NotWorkflowAppError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@@ -14,6 +14,7 @@ from controllers.web.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
@@ -30,6 +31,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,6 +82,8 @@ class WorkflowRunApi(WebApiResource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@@ -31,7 +31,11 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AgentAppGeneratorError,
|
||||
AgentAppNotPublishedError,
|
||||
AgentSessionSnapshotIncompatibleError,
|
||||
)
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore
|
||||
@@ -531,6 +535,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
except AgentSessionSnapshotIncompatibleError as error:
|
||||
logger.info(
|
||||
"Agent App session snapshot no longer matches the current composition",
|
||||
extra={
|
||||
"agent_id": application_generate_entity.agent_id,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
queue_manager.publish_error(error, PublishFrom.APPLICATION_MANAGER)
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error in Agent App generate worker")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE = "agent_session_configuration_changed"
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE = (
|
||||
"The Agent configuration changed after this conversation started. Start a new conversation to continue."
|
||||
)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppNotPublishedError(AgentAppGeneratorError):
|
||||
"""Raised when a public Agent App runtime is requested before publish."""
|
||||
|
||||
|
||||
class AgentSessionSnapshotIncompatibleError(AppGenerateError):
|
||||
"""Raised when a retained session snapshot no longer matches the current composition."""
|
||||
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
status_code = 409
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE)
|
||||
|
||||
@@ -50,6 +50,8 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import expand_prompt_mentions
|
||||
|
||||
from .errors import AgentSessionSnapshotIncompatibleError
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
"""Raised when Agent App state cannot be mapped to a valid run request."""
|
||||
@@ -191,6 +193,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._validate_session_snapshot_layers(request)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(
|
||||
request=request,
|
||||
@@ -199,6 +202,24 @@ class AgentAppRuntimeRequestBuilder:
|
||||
binding_id=context.binding_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_snapshot_layers(request: CreateRunRequest) -> None:
|
||||
"""Reject stale snapshots before they reach the Agent backend.
|
||||
|
||||
Draft rows are updated in place, so their IDs cannot prove that a
|
||||
retained snapshot still belongs to the current composition. Agenton
|
||||
requires the ordered layer names to match exactly; enforce the same
|
||||
invariant at the API boundary and return a product-level error.
|
||||
"""
|
||||
|
||||
snapshot = request.session_snapshot
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_layer_names = tuple(layer.name for layer in snapshot.layers)
|
||||
composition_layer_names = tuple(layer.name for layer in request.composition.layers)
|
||||
if snapshot_layer_names != composition_layer_names:
|
||||
raise AgentSessionSnapshotIncompatibleError()
|
||||
|
||||
def _build_tool_layers(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendError, AgentBackendRunFailedError
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@@ -125,6 +126,13 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
if isinstance(e, AppGenerateError):
|
||||
return {
|
||||
"code": e.error_code,
|
||||
"status": e.status_code,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
error_responses: dict[type[Exception], dict[str, JsonValue]] = {
|
||||
ValueError: {"code": "invalid_param", "status": 400},
|
||||
ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400},
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
class AppGenerateError(ValueError):
|
||||
"""Base class for application-generation errors with a stable response contract."""
|
||||
|
||||
error_code: str
|
||||
status_code: int
|
||||
|
||||
|
||||
class GenerateTaskStoppedError(Exception):
|
||||
pass
|
||||
|
||||
@@ -12,6 +12,7 @@ from core.mcp import types as mcp_types
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import TriggerWorkflowServiceModeUnavailableError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,11 +94,16 @@ def handle_mcp_request(
|
||||
result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
|
||||
def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError:
|
||||
def create_error_response(
|
||||
code: int,
|
||||
message: str,
|
||||
*,
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> mcp_types.JSONRPCError:
|
||||
"""Create error response with error code and message"""
|
||||
from core.mcp.types import ErrorData
|
||||
|
||||
error_data = ErrorData(code=code, message=message)
|
||||
error_data = ErrorData(code=code, message=message, data=data)
|
||||
return mcp_types.JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
@@ -131,6 +137,12 @@ def handle_mcp_request(
|
||||
case _:
|
||||
return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}")
|
||||
|
||||
except TriggerWorkflowServiceModeUnavailableError as e:
|
||||
return create_error_response(
|
||||
mcp_types.INVALID_REQUEST,
|
||||
str(e),
|
||||
data={"code": e.error_code},
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.exception("Invalid params")
|
||||
return create_error_response(mcp_types.INVALID_PARAMS, str(e))
|
||||
|
||||
@@ -1573,10 +1573,6 @@ class TraceQueueManager:
|
||||
return None
|
||||
|
||||
def persist_trace_task(self, task: TraceTask, *, file_id: str | None = None) -> dict[str, str] | None:
|
||||
if not (self._enterprise_telemetry_enabled or self.trace_instance):
|
||||
return None
|
||||
|
||||
task.app_id = self.app_id
|
||||
storage_id = self._resolve_storage_id(task)
|
||||
if storage_id is None:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""add normalized email to accounts
|
||||
|
||||
The normalized value is indexed but intentionally not unique. Existing
|
||||
installations may already contain equivalent accounts, and registration-time
|
||||
validation must preserve those records while preventing new collisions.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9b7c6d5e4f3a"
|
||||
down_revision = "a4f8d2c9e1b0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _backfill_normalized_emails() -> None:
|
||||
dialect_name = op.get_context().dialect.name
|
||||
if dialect_name == "postgresql":
|
||||
op.execute(
|
||||
sa.text(
|
||||
r"""
|
||||
UPDATE accounts
|
||||
SET normalized_email = CASE
|
||||
WHEN LOWER(email) LIKE '%@gmail.com'
|
||||
OR LOWER(email) LIKE '%@googlemail.com'
|
||||
THEN regexp_replace(
|
||||
regexp_replace(split_part(LOWER(email), '@', 1), '\+.*$', ''),
|
||||
'\.', '', 'g'
|
||||
) || '@gmail.com'
|
||||
ELSE LOWER(email)
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if dialect_name in {"mysql", "mariadb"}:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE accounts
|
||||
SET normalized_email = CASE
|
||||
WHEN LOWER(email) LIKE '%@gmail.com'
|
||||
OR LOWER(email) LIKE '%@googlemail.com'
|
||||
THEN CONCAT(
|
||||
REPLACE(
|
||||
SUBSTRING_INDEX(SUBSTRING_INDEX(LOWER(email), '@', 1), '+', 1),
|
||||
'.', ''
|
||||
),
|
||||
'@gmail.com'
|
||||
)
|
||||
ELSE LOWER(email)
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if dialect_name == "sqlite":
|
||||
local_part = "substr(lower(email), 1, instr(lower(email), '@') - 1)"
|
||||
local_part_without_alias = f"substr({local_part} || '+', 1, instr({local_part} || '+', '+') - 1)"
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
UPDATE accounts
|
||||
SET normalized_email = CASE
|
||||
WHEN lower(email) LIKE '%@gmail.com'
|
||||
OR lower(email) LIKE '%@googlemail.com'
|
||||
THEN replace({local_part_without_alias}, '.', '') || '@gmail.com'
|
||||
ELSE lower(email)
|
||||
END
|
||||
"""
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
raise RuntimeError(f"Unsupported database dialect for normalized email migration: {dialect_name}")
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("accounts", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("normalized_email", sa.String(length=255), nullable=True))
|
||||
|
||||
_backfill_normalized_emails()
|
||||
|
||||
with op.batch_alter_table("accounts", schema=None) as batch_op:
|
||||
batch_op.create_index("account_normalized_email_idx", ["normalized_email"], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("accounts", schema=None) as batch_op:
|
||||
batch_op.drop_index("account_normalized_email_idx")
|
||||
batch_op.drop_column("normalized_email")
|
||||
@@ -88,13 +88,18 @@ class AccountStatus(enum.StrEnum):
|
||||
|
||||
class Account(UserMixin, TypeBase):
|
||||
__tablename__ = "accounts"
|
||||
__table_args__ = (sa.PrimaryKeyConstraint("id", name="account_pkey"), sa.Index("account_email_idx", "email"))
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="account_pkey"),
|
||||
sa.Index("account_email_idx", "email"),
|
||||
sa.Index("account_normalized_email_idx", "normalized_email"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[str] = mapped_column(String(255))
|
||||
normalized_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
password: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
password_salt: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
avatar: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
|
||||
@@ -2163,7 +2163,7 @@ Execute a workflow. Cannot be executed without a published workflow.
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br>**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | Workflow not found | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
@@ -2240,7 +2240,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br>**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | `not_found` : Workflow not found. | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
|
||||
@@ -4,18 +4,18 @@ import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import override
|
||||
|
||||
from langfuse import Langfuse
|
||||
import httpx
|
||||
from langfuse import __version__ as langfuse_version
|
||||
from langfuse.api import (
|
||||
CreateGenerationBody,
|
||||
CreateSpanBody,
|
||||
IngestionEvent_GenerationCreate,
|
||||
IngestionEvent_SpanCreate,
|
||||
IngestionEvent_TraceCreate,
|
||||
LangfuseAPI,
|
||||
TraceBody,
|
||||
)
|
||||
from langfuse.api.commons.types.usage import Usage
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.ops.base_trace_instance import BaseTraceInstance
|
||||
@@ -55,36 +55,29 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
langfuse_config: LangfuseConfig,
|
||||
):
|
||||
super().__init__(langfuse_config)
|
||||
# Isolated TracerProvider prevents the langfuse v3 SDK from attaching its
|
||||
# SpanProcessor to the global OpenTelemetry TracerProvider, which would
|
||||
# otherwise siphon every Flask/Celery/SQLAlchemy span in the process into
|
||||
# this tenant's Langfuse project. See langfuse upgrade guide v2 -> v3.
|
||||
self._tracer_provider: TracerProvider | None = TracerProvider(
|
||||
resource=Resource.create({"service.name": "dify-langfuse-app-trace"}),
|
||||
)
|
||||
self.langfuse_client = Langfuse(
|
||||
public_key=langfuse_config.public_key,
|
||||
secret_key=langfuse_config.secret_key,
|
||||
host=langfuse_config.host,
|
||||
tracer_provider=self._tracer_provider,
|
||||
timeout = int(os.environ.get("LANGFUSE_TIMEOUT", 5))
|
||||
self._http_client: httpx.Client | None = httpx.Client(timeout=timeout)
|
||||
self.langfuse_client = LangfuseAPI(
|
||||
base_url=langfuse_config.host,
|
||||
username=langfuse_config.public_key,
|
||||
password=langfuse_config.secret_key,
|
||||
x_langfuse_sdk_name="python",
|
||||
x_langfuse_sdk_version=langfuse_version,
|
||||
x_langfuse_public_key=langfuse_config.public_key,
|
||||
timeout=timeout,
|
||||
httpx_client=self._http_client,
|
||||
)
|
||||
self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Flush and shut down the isolated TracerProvider.
|
||||
|
||||
Called explicitly when the trace instance is evicted from the cache, or
|
||||
implicitly via ``__del__`` on garbage collection. Idempotent.
|
||||
"""
|
||||
provider = getattr(self, "_tracer_provider", None)
|
||||
if provider is None:
|
||||
client = getattr(self, "_http_client", None)
|
||||
if client is None:
|
||||
return
|
||||
self._http_client = None
|
||||
try:
|
||||
provider.shutdown()
|
||||
client.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to shut down Langfuse TracerProvider", exc_info=True)
|
||||
finally:
|
||||
self._tracer_provider = None
|
||||
logger.debug("Failed to close Langfuse HTTP client", exc_info=True)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
@@ -500,7 +493,7 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
id=self._make_event_id(),
|
||||
timestamp=self._now_iso(),
|
||||
)
|
||||
self.langfuse_client.api.ingestion.batch(batch=[event])
|
||||
self.langfuse_client.ingestion.batch(batch=[event])
|
||||
logger.debug("LangFuse Trace created successfully")
|
||||
except Exception as e:
|
||||
raise ValueError(f"LangFuse Failed to create trace: {str(e)}")
|
||||
@@ -527,7 +520,7 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
id=self._make_event_id(),
|
||||
timestamp=self._now_iso(),
|
||||
)
|
||||
self.langfuse_client.api.ingestion.batch(batch=[event])
|
||||
self.langfuse_client.ingestion.batch(batch=[event])
|
||||
logger.debug("LangFuse Span created successfully")
|
||||
except Exception as e:
|
||||
raise ValueError(f"LangFuse Failed to create span: {str(e)}")
|
||||
@@ -576,7 +569,7 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
id=self._make_event_id(),
|
||||
timestamp=self._now_iso(),
|
||||
)
|
||||
self.langfuse_client.api.ingestion.batch(batch=[event])
|
||||
self.langfuse_client.ingestion.batch(batch=[event])
|
||||
logger.debug("LangFuse Generation created successfully")
|
||||
except Exception as e:
|
||||
raise ValueError(f"LangFuse Failed to create generation: {str(e)}")
|
||||
@@ -590,14 +583,17 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
|
||||
def api_check(self):
|
||||
try:
|
||||
return self.langfuse_client.auth_check()
|
||||
projects = self.langfuse_client.projects.get()
|
||||
except Exception as e:
|
||||
logger.debug("LangFuse API check failed", exc_info=True)
|
||||
raise ValueError(f"LangFuse API check failed: {str(e)}")
|
||||
if not projects.data:
|
||||
raise ValueError("LangFuse API check failed: no project found for the provided credentials")
|
||||
return True
|
||||
|
||||
def get_project_key(self):
|
||||
try:
|
||||
projects = self.langfuse_client.api.projects.get()
|
||||
projects = self.langfuse_client.projects.get()
|
||||
return projects.data[0].id
|
||||
except Exception as e:
|
||||
logger.debug("LangFuse get project key failed", exc_info=True)
|
||||
|
||||
@@ -47,97 +47,60 @@ def langfuse_config():
|
||||
def trace_instance(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
# Mock Langfuse client to avoid network calls
|
||||
mock_client = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: mock_client)
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.LangfuseAPI", lambda **kwargs: mock_client)
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
return instance
|
||||
|
||||
|
||||
def test_init(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
mock_langfuse = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse)
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.LangfuseAPI", mock_langfuse)
|
||||
monkeypatch.setenv("FILES_URL", "http://test.url")
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
mock_langfuse.assert_called_once()
|
||||
kwargs = mock_langfuse.call_args.kwargs
|
||||
assert kwargs["public_key"] == langfuse_config.public_key
|
||||
assert kwargs["secret_key"] == langfuse_config.secret_key
|
||||
assert kwargs["host"] == langfuse_config.host
|
||||
assert isinstance(kwargs["tracer_provider"], TracerProvider)
|
||||
assert kwargs["tracer_provider"] is instance._tracer_provider
|
||||
assert kwargs["username"] == langfuse_config.public_key
|
||||
assert kwargs["password"] == langfuse_config.secret_key
|
||||
assert kwargs["base_url"] == langfuse_config.host
|
||||
assert instance.file_base_url == "http://test.url"
|
||||
|
||||
|
||||
def test_init_passes_isolated_tracer_provider_to_langfuse(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Regression test for langfuse v3 SDK side effect.
|
||||
def test_same_public_key_uses_independent_api_clients(monkeypatch: pytest.MonkeyPatch):
|
||||
clients = [MagicMock(), MagicMock()]
|
||||
create_client = MagicMock(side_effect=clients)
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.LangfuseAPI", create_client)
|
||||
|
||||
Without an explicit ``tracer_provider=`` kwarg, the Langfuse v3 SDK
|
||||
attaches a ``LangfuseSpanProcessor`` to the *global* OpenTelemetry
|
||||
TracerProvider — siphoning every Flask / Celery / SQLAlchemy span in the
|
||||
process into the tenant's Langfuse project. See langfuse upgrade-path
|
||||
docs (v2 -> v3) and GitHub discussion #9136.
|
||||
|
||||
The fix is to construct an isolated ``TracerProvider`` and pass it via
|
||||
``tracer_provider=`` so the SDK never touches the global one.
|
||||
"""
|
||||
from opentelemetry import trace as otel_trace_api
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_langfuse(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", fake_langfuse)
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
# 1. tracer_provider kwarg must be supplied (drives the no-pollution branch
|
||||
# in langfuse.LangfuseResourceManager._init_tracer_provider).
|
||||
assert "tracer_provider" in captured, (
|
||||
"Langfuse() must receive an explicit tracer_provider=; without it the "
|
||||
"v3 SDK attaches its SpanProcessor to the global OTEL TracerProvider."
|
||||
first = LangFuseDataTrace(
|
||||
LangfuseConfig(public_key="shared", secret_key="secret-a", host="https://tenant-a.example")
|
||||
)
|
||||
second = LangFuseDataTrace(
|
||||
LangfuseConfig(public_key="shared", secret_key="secret-b", host="https://tenant-b.example")
|
||||
)
|
||||
|
||||
passed_provider = captured["tracer_provider"]
|
||||
assert isinstance(passed_provider, TracerProvider)
|
||||
assert passed_provider is instance._tracer_provider
|
||||
|
||||
# 2. The instance's provider must not be the global one.
|
||||
global_provider = otel_trace_api.get_tracer_provider()
|
||||
assert passed_provider is not global_provider
|
||||
|
||||
|
||||
def test_close_shuts_down_tracer_provider(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider = instance._tracer_provider
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
assert instance._tracer_provider is None
|
||||
assert first.langfuse_client is clients[0]
|
||||
assert second.langfuse_client is clients[1]
|
||||
assert [
|
||||
(call.kwargs["base_url"], call.kwargs["username"], call.kwargs["password"])
|
||||
for call in create_client.call_args_list
|
||||
] == [
|
||||
("https://tenant-a.example", "shared", "secret-a"),
|
||||
("https://tenant-b.example", "shared", "secret-b"),
|
||||
]
|
||||
|
||||
|
||||
def test_close_is_idempotent(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
http_client = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.httpx.Client", lambda **kwargs: http_client)
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.LangfuseAPI", MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(instance._tracer_provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
http_client.close.assert_called_once()
|
||||
|
||||
|
||||
def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -623,11 +586,11 @@ def test_generate_name_trace(trace_instance):
|
||||
def test_add_trace_success(trace_instance):
|
||||
data = LangfuseTrace(id="t1", name="trace")
|
||||
trace_instance.add_trace(data)
|
||||
trace_instance.langfuse_client.api.ingestion.batch.assert_called_once()
|
||||
trace_instance.langfuse_client.ingestion.batch.assert_called_once()
|
||||
|
||||
|
||||
def test_add_trace_error(trace_instance):
|
||||
trace_instance.langfuse_client.api.ingestion.batch.side_effect = Exception("error")
|
||||
trace_instance.langfuse_client.ingestion.batch.side_effect = Exception("error")
|
||||
data = LangfuseTrace(id="t1", name="trace")
|
||||
with pytest.raises(ValueError, match="LangFuse Failed to create trace: error"):
|
||||
trace_instance.add_trace(data)
|
||||
@@ -636,11 +599,11 @@ def test_add_trace_error(trace_instance):
|
||||
def test_add_span_success(trace_instance):
|
||||
data = LangfuseSpan(id="s1", name="span", trace_id="t1")
|
||||
trace_instance.add_span(data)
|
||||
trace_instance.langfuse_client.api.ingestion.batch.assert_called_once()
|
||||
trace_instance.langfuse_client.ingestion.batch.assert_called_once()
|
||||
|
||||
|
||||
def test_add_span_error(trace_instance):
|
||||
trace_instance.langfuse_client.api.ingestion.batch.side_effect = Exception("error")
|
||||
trace_instance.langfuse_client.ingestion.batch.side_effect = Exception("error")
|
||||
data = LangfuseSpan(id="s1", name="span", trace_id="t1")
|
||||
with pytest.raises(ValueError, match="LangFuse Failed to create span: error"):
|
||||
trace_instance.add_span(data)
|
||||
@@ -656,11 +619,11 @@ def test_update_span(trace_instance):
|
||||
def test_add_generation_success(trace_instance):
|
||||
data = LangfuseGeneration(id="g1", name="gen", trace_id="t1")
|
||||
trace_instance.add_generation(data)
|
||||
trace_instance.langfuse_client.api.ingestion.batch.assert_called_once()
|
||||
trace_instance.langfuse_client.ingestion.batch.assert_called_once()
|
||||
|
||||
|
||||
def test_add_generation_error(trace_instance):
|
||||
trace_instance.langfuse_client.api.ingestion.batch.side_effect = Exception("error")
|
||||
trace_instance.langfuse_client.ingestion.batch.side_effect = Exception("error")
|
||||
data = LangfuseGeneration(id="g1", name="gen", trace_id="t1")
|
||||
with pytest.raises(ValueError, match="LangFuse Failed to create generation: error"):
|
||||
trace_instance.add_generation(data)
|
||||
@@ -674,12 +637,18 @@ def test_update_generation(trace_instance):
|
||||
|
||||
|
||||
def test_api_check_success(trace_instance):
|
||||
trace_instance.langfuse_client.auth_check.return_value = True
|
||||
trace_instance.langfuse_client.projects.get.return_value = MagicMock(data=[MagicMock()])
|
||||
assert trace_instance.api_check() is True
|
||||
|
||||
|
||||
def test_api_check_rejects_empty_project_list(trace_instance):
|
||||
trace_instance.langfuse_client.projects.get.return_value = MagicMock(data=[])
|
||||
with pytest.raises(ValueError, match="no project found for the provided credentials"):
|
||||
trace_instance.api_check()
|
||||
|
||||
|
||||
def test_api_check_error(trace_instance):
|
||||
trace_instance.langfuse_client.auth_check.side_effect = Exception("fail")
|
||||
trace_instance.langfuse_client.projects.get.side_effect = Exception("fail")
|
||||
with pytest.raises(ValueError, match="LangFuse API check failed: fail"):
|
||||
trace_instance.api_check()
|
||||
|
||||
@@ -687,12 +656,12 @@ def test_api_check_error(trace_instance):
|
||||
def test_get_project_key_success(trace_instance):
|
||||
mock_data = MagicMock()
|
||||
mock_data.id = "proj-1"
|
||||
trace_instance.langfuse_client.api.projects.get.return_value = MagicMock(data=[mock_data])
|
||||
trace_instance.langfuse_client.projects.get.return_value = MagicMock(data=[mock_data])
|
||||
assert trace_instance.get_project_key() == "proj-1"
|
||||
|
||||
|
||||
def test_get_project_key_error(trace_instance):
|
||||
trace_instance.langfuse_client.api.projects.get.side_effect = Exception("fail")
|
||||
trace_instance.langfuse_client.projects.get.side_effect = Exception("fail")
|
||||
with pytest.raises(ValueError, match="LangFuse get project key failed: fail"):
|
||||
trace_instance.get_project_key()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from graphon.enums import BuiltinNodeTypes
|
||||
|
||||
|
||||
def _create_trace_instance() -> LangFuseDataTrace:
|
||||
with patch("dify_trace_langfuse.langfuse_trace.Langfuse", autospec=True):
|
||||
with patch("dify_trace_langfuse.langfuse_trace.LangfuseAPI", autospec=True):
|
||||
return LangFuseDataTrace(
|
||||
LangfuseConfig(
|
||||
public_key="public-key",
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Account, AccountIntegrate, AccountStatus, InvitationCode, InvitationCodeStatus
|
||||
from services.account_email import normalize_email
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountCredentials,
|
||||
@@ -137,6 +138,7 @@ class SQLAlchemyAccountRepository(AccountRepository):
|
||||
return AccountEmailResetResult(status=AccountEmailResetStatus.EMAIL_IN_USE)
|
||||
|
||||
account.email = new_email
|
||||
account.normalized_email = normalize_email(new_email)
|
||||
session.execute(delete(AccountIntegrate).where(AccountIntegrate.account_id == account_id))
|
||||
session.flush()
|
||||
return AccountEmailResetResult(
|
||||
|
||||
11
api/services/account_email.py
Normal file
11
api/services/account_email.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Account email normalization rules."""
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""Normalize an account email for identity comparisons."""
|
||||
normalized_email = email.lower()
|
||||
local_part, separator, domain = normalized_email.rpartition("@")
|
||||
if separator and domain in {"gmail.com", "googlemail.com"}:
|
||||
local_part = local_part.split("+", 1)[0].replace(".", "")
|
||||
return f"{local_part}@gmail.com"
|
||||
return normalized_email
|
||||
@@ -47,6 +47,7 @@ from models.account import (
|
||||
)
|
||||
from models.dataset import Dataset
|
||||
from models.model import App, DifySetup
|
||||
from services.account_email import normalize_email
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeResult,
|
||||
@@ -62,6 +63,7 @@ from services.entities.auth_entities import (
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountNotLinkTenantError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
@@ -301,6 +303,18 @@ class AccountService:
|
||||
).scalar_one_or_none()
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def has_account_with_normalized_email(email: str, *, session: Session) -> bool:
|
||||
"""Check the normalized identity through its indexed column."""
|
||||
normalized_email = normalize_email(email)
|
||||
row = session.scalar(select(Account.id).where(Account.normalized_email == normalized_email).limit(1))
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def ensure_registration_email_available(email: str, *, session: Session) -> None:
|
||||
if AccountService.has_account_with_normalized_email(email, session=session):
|
||||
raise AccountNormalizedEmailAlreadyInUseError("An account with an equivalent email already exists.")
|
||||
|
||||
@staticmethod
|
||||
def get_account_by_id(account_id: str, *, session: Session) -> Account | None:
|
||||
"""Plain ``Account`` getter — no banned check, no tenant rotation,
|
||||
@@ -423,6 +437,7 @@ class AccountService:
|
||||
is_setup: bool | None = False,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
check_normalized_email: bool = False,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@@ -432,6 +447,9 @@ class AccountService:
|
||||
|
||||
raise AccountNotFound()
|
||||
|
||||
if check_normalized_email:
|
||||
AccountService.ensure_registration_email_available(email, session=session)
|
||||
|
||||
# A licensed seat is one Account row, deployment-wide; joining an existing
|
||||
# account into another workspace does not pass through here and costs no seat.
|
||||
# get_license() carries the full license payload that server-side enforcement needs;
|
||||
@@ -473,6 +491,7 @@ class AccountService:
|
||||
account = Account(
|
||||
name=name,
|
||||
email=email,
|
||||
normalized_email=normalize_email(email),
|
||||
password=password_to_set,
|
||||
password_salt=salt_to_set,
|
||||
interface_language=interface_language,
|
||||
@@ -493,6 +512,7 @@ class AccountService:
|
||||
password: str | None = None,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
check_normalized_email: bool = False,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@@ -504,6 +524,7 @@ class AccountService:
|
||||
password=password,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=check_normalized_email,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -551,6 +572,7 @@ class AccountService:
|
||||
def update_account_email(account: Account, email: str, session: Session) -> Account:
|
||||
"""Update account email"""
|
||||
account.email = email
|
||||
account.normalized_email = normalize_email(email)
|
||||
account_integrate = session.scalar(
|
||||
select(AccountIntegrate).where(AccountIntegrate.account_id == account.id).limit(1)
|
||||
)
|
||||
@@ -1899,6 +1921,7 @@ class RegisterService:
|
||||
create_workspace_required: bool | None = True,
|
||||
timezone: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
check_normalized_email: bool = True,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Account:
|
||||
@@ -1914,6 +1937,7 @@ class RegisterService:
|
||||
is_setup=is_setup,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=check_normalized_email,
|
||||
session=session,
|
||||
)
|
||||
account.status = status or AccountStatus.ACTIVE
|
||||
@@ -1991,6 +2015,7 @@ class RegisterService:
|
||||
language=language,
|
||||
status=AccountStatus.PENDING,
|
||||
is_setup=True,
|
||||
check_normalized_email=True,
|
||||
session=session,
|
||||
)
|
||||
TenantService.create_tenant_member(tenant, account, session, tenant_join_role)
|
||||
|
||||
@@ -21,11 +21,17 @@ from core.app.features.rate_limiting import RateLimit
|
||||
from core.app.features.rate_limiting.rate_limit import rate_limit_context
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
|
||||
from core.db import session_factory
|
||||
from core.trigger.constants import is_trigger_node_type
|
||||
from enums import DeploymentEdition, QuotaType
|
||||
from extensions.otel import AppGenerateHandler, trace_span
|
||||
from models.model import Account, App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
QuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow_service import WorkflowService
|
||||
@@ -34,6 +40,13 @@ from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSE_TASK_START_FALLBACK_MS = 200
|
||||
_MANUAL_WORKFLOW_INVOKE_SOURCES = frozenset(
|
||||
{
|
||||
InvokeFrom.OPENAPI,
|
||||
InvokeFrom.SERVICE_API,
|
||||
InvokeFrom.WEB_APP,
|
||||
}
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from controllers.console.app.workflow import LoopNodeRunPayload
|
||||
@@ -290,6 +303,7 @@ class AppGenerateService:
|
||||
case AppMode.WORKFLOW:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session)
|
||||
cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from)
|
||||
if streaming:
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
@@ -343,6 +357,16 @@ class AppGenerateService:
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_workflow_service_mode_available(*, workflow: Workflow, invoke_from: InvokeFrom) -> None:
|
||||
if invoke_from not in _MANUAL_WORKFLOW_INVOKE_SOURCES:
|
||||
return
|
||||
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
node_type = node_data.get("type")
|
||||
if isinstance(node_type, str) and is_trigger_node_type(node_type):
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
|
||||
@staticmethod
|
||||
def _get_max_active_requests(app: App) -> int:
|
||||
"""
|
||||
|
||||
@@ -394,6 +394,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
"app_library.access",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
@@ -401,6 +402,7 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"dataset.create_and_management",
|
||||
"dataset.external.connect",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
|
||||
@@ -9,6 +9,14 @@ class AccountRegisterError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class AccountEmailAlreadyInUseError(AccountRegisterError):
|
||||
pass
|
||||
|
||||
|
||||
class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError):
|
||||
pass
|
||||
|
||||
|
||||
class EmailDomainSuspendedError(AccountRegisterError):
|
||||
def __init__(self, description: str = "This email domain has been suspended."):
|
||||
super().__init__(description)
|
||||
|
||||
@@ -18,6 +18,21 @@ class WorkflowIdFormatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE = "trigger_workflow_service_mode_unavailable"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE = (
|
||||
"This workflow uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
)
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(Exception):
|
||||
"""Raised when a trigger-entry Workflow is invoked through a manual service surface."""
|
||||
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE)
|
||||
|
||||
|
||||
class QuotaExceededError(ValueError):
|
||||
"""Raised when billing quota is exceeded for a feature."""
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ from controllers.console.agent.roster import (
|
||||
from controllers.console.app import completion as completion_controller
|
||||
from controllers.console.app import message as message_controller
|
||||
from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from controllers.console.app.error import AgentSessionConfigurationChangedError, CompletionRequestError
|
||||
from controllers.console.app.message import (
|
||||
AgentChatMessageListApi,
|
||||
AgentMessageApi,
|
||||
@@ -1621,6 +1621,38 @@ def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_session_configuration_error() -> None:
|
||||
class ClosableStream:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self._chunks = iter(
|
||||
[
|
||||
"event: ping\n\n",
|
||||
(
|
||||
'data: {"event":"error","message":"Start a new conversation to continue.",'
|
||||
'"code":"agent_session_configuration_changed","status":409}\n\n'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self) -> str:
|
||||
return next(self._chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
stream = ClosableStream()
|
||||
with pytest.raises(AgentSessionConfigurationChangedError) as exc_info:
|
||||
completion_controller._raise_agent_stream_error_before_response(stream)
|
||||
assert exc_info.value.code == 409
|
||||
assert exc_info.value.error_code == "agent_session_configuration_changed"
|
||||
assert "Start a new conversation" in exc_info.value.description
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
|
||||
stream = iter(
|
||||
["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n']
|
||||
|
||||
@@ -13,11 +13,13 @@ from controllers.console.auth.email_register import (
|
||||
EmailRegisterResetApi,
|
||||
EmailRegisterSendEmailApi,
|
||||
)
|
||||
from controllers.console.auth.error import NormalizedEmailAlreadyInUseError
|
||||
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account
|
||||
from services.entities.feature_entities import SystemFeatureModel
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
@@ -30,6 +32,14 @@ def _cloud_edition(config_overrides: Callable[..., None]) -> None:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
|
||||
|
||||
def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None:
|
||||
error = NormalizedEmailAlreadyInUseError()
|
||||
|
||||
assert error.code == 400
|
||||
assert error.data is not None
|
||||
assert error.data["code"] == "normalized_email_already_in_use"
|
||||
|
||||
|
||||
class TestEmailRegisterSendEmailApi:
|
||||
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.email_register.AccountService.send_email_register_email")
|
||||
@@ -166,6 +176,7 @@ class TestEmailRegisterResetApi:
|
||||
("service_error", "expected_error"),
|
||||
[
|
||||
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
|
||||
(AccountNormalizedEmailAlreadyInUseError(), NormalizedEmailAlreadyInUseError),
|
||||
(AccountRegisterError("frozen"), AccountInFreezeError),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ def test_create_new_account_uses_requested_language(mock_create_account):
|
||||
interface_language="zh-Hans",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address=None,
|
||||
check_normalized_email=True,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
@@ -676,6 +676,7 @@ class TestEmailCodeLoginApi:
|
||||
interface_language="en-US",
|
||||
timezone="Asia/Shanghai",
|
||||
ip_address="203.0.113.10",
|
||||
check_normalized_email=True,
|
||||
session=ANY,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from controllers.console.auth.error import (
|
||||
AuthenticationFailedError,
|
||||
EmailPasswordLoginLimitError,
|
||||
InvalidEmailError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
)
|
||||
from controllers.console.auth.login import EmailCodeLoginApi, LoginApi, LogoutApi, ResetPasswordSendEmailApi
|
||||
from controllers.console.error import (
|
||||
@@ -37,6 +38,7 @@ from services.email_code_login_challenge import EmailCodeLoginChallengeResult, E
|
||||
from services.entities.auth_entities import LoginFailureReason
|
||||
from services.errors.account import (
|
||||
AccountLoginError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
@@ -329,6 +331,7 @@ class TestLoginApi:
|
||||
("service_error", "expected_error"),
|
||||
[
|
||||
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
|
||||
(AccountNormalizedEmailAlreadyInUseError(), NormalizedEmailAlreadyInUseError),
|
||||
(AccountRegisterError("frozen"), AccountInFreezeError),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.console.datasets.data_source import DataSourceApi
|
||||
from controllers.console.datasets.rag_pipeline.datasource_auth import DatasourceAuth
|
||||
from controllers.console.workspace.model_providers import ModelProviderCredentialApi
|
||||
from controllers.console.workspace.models import DefaultModelApi, ModelProviderModelApi, ModelProviderModelCredentialApi
|
||||
from controllers.console.workspace.tool_providers import ToolBuiltinProviderAddApi, ToolOAuthCustomClient
|
||||
|
||||
|
||||
@@ -36,7 +35,6 @@ def test_workspace_credential_mutations_require_management_permission(
|
||||
"method",
|
||||
[
|
||||
ModelProviderCredentialApi.get,
|
||||
ModelProviderModelCredentialApi.get,
|
||||
],
|
||||
)
|
||||
def test_model_provider_credential_get_requires_admin_and_rbac(
|
||||
@@ -87,20 +85,15 @@ def test_datasource_auth_get_requires_edit_and_rbac() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
DefaultModelApi.get,
|
||||
ModelProviderModelApi.get,
|
||||
AgentAppApi.get,
|
||||
AgentComposerApi.get,
|
||||
],
|
||||
)
|
||||
def test_workspace_model_preferences_get_require_admin_and_rbac(
|
||||
method: FunctionType,
|
||||
) -> None:
|
||||
"""GET endpoints that return workspace model preferences must enforce
|
||||
the same admin + RBAC gates as their sibling POST/DELETE methods."""
|
||||
legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
|
||||
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
|
||||
|
||||
def test_agent_app_get_requires_rbac(method: FunctionType) -> None:
|
||||
"""GET endpoints that return agent app details or composer state must enforce
|
||||
the same RBAC gates as their sibling PUT/DELETE methods."""
|
||||
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
|
||||
rbac_config = getclosurevars(rbac_wrapper).nonlocals
|
||||
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
|
||||
assert rbac_config["scene"] == RBACPermission.PLUGIN_PREFERENCES
|
||||
assert rbac_config["scene"] == RBACPermission.AGENT_MANAGE
|
||||
assert rbac_config["resource_required"] is False
|
||||
|
||||
@@ -652,6 +652,49 @@ class TestModelValidationInjection:
|
||||
|
||||
assert payload == self.Payload(name="alpha", count=2)
|
||||
|
||||
def test_should_inject_delete_payload_from_query_params(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
class Handler:
|
||||
@model_validate(TestModelValidationInjection.Payload)
|
||||
def delete(self, payload: TestModelValidationInjection.Payload):
|
||||
return payload
|
||||
|
||||
with app.test_request_context("/items?name=alpha&count=2", method="DELETE"):
|
||||
payload = Handler().delete()
|
||||
|
||||
assert payload == self.Payload(name="alpha", count=2)
|
||||
|
||||
def test_should_inject_delete_payload_from_json_body(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
class Handler:
|
||||
@model_validate(TestModelValidationInjection.Payload)
|
||||
def delete(self, payload: TestModelValidationInjection.Payload):
|
||||
return payload
|
||||
|
||||
with app.test_request_context("/items", method="DELETE", json={"name": "alpha", "count": 2}):
|
||||
payload = Handler().delete()
|
||||
|
||||
assert payload == self.Payload(name="alpha", count=2)
|
||||
|
||||
def test_should_prefer_delete_query_params_over_json_body(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
class Handler:
|
||||
@model_validate(TestModelValidationInjection.Payload)
|
||||
def delete(self, payload: TestModelValidationInjection.Payload):
|
||||
return payload
|
||||
|
||||
with app.test_request_context(
|
||||
"/items?name=alpha&count=2",
|
||||
method="DELETE",
|
||||
json={"name": "beta", "count": 9},
|
||||
):
|
||||
payload = Handler().delete()
|
||||
|
||||
assert payload == self.Payload(name="alpha", count=2)
|
||||
|
||||
def test_should_raise_unprocessable_entity_for_invalid_payload(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import pytest
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
|
||||
from controllers.openapi.app_run import _translate_service_errors
|
||||
from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
|
||||
@@ -27,3 +31,11 @@ def test_translate_maps_workflow_quota_to_rate_limit_error():
|
||||
raise InvokeRateLimitError("workflow quota exhausted")
|
||||
assert exc.value.error_code == "rate_limit_error"
|
||||
assert exc.value.code == 429
|
||||
|
||||
|
||||
def test_translate_maps_trigger_workflow_to_stable_unavailable_error():
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc:
|
||||
with _translate_service_errors():
|
||||
raise TriggerWorkflowServiceModeUnavailableServiceError()
|
||||
assert exc.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
assert exc.value.code == 403
|
||||
|
||||
@@ -27,7 +27,11 @@ from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError
|
||||
from controllers.service_api.app.error import (
|
||||
NotWorkflowAppError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.app.workflow import (
|
||||
AppQueueManager,
|
||||
GraphEngineManager,
|
||||
@@ -50,7 +54,13 @@ from models.model import App, AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
IsDraftWorkflowError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@@ -581,6 +591,32 @@ class TestWorkflowRunApi:
|
||||
with pytest.raises(InvokeRateLimitHttpError):
|
||||
handler(api, session=sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_trigger_workflow_returns_stable_unavailable_error(
|
||||
self,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()),
|
||||
)
|
||||
api = WorkflowRunApi()
|
||||
handler = unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
handler(
|
||||
api,
|
||||
session=sqlite_session,
|
||||
app_model=_make_app_model(),
|
||||
end_user=_make_end_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
def test_sandbox_billing_does_not_gate_default_workflow_run(
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
@@ -609,6 +645,33 @@ class TestWorkflowRunApi:
|
||||
|
||||
|
||||
class TestWorkflowRunByIdApi:
|
||||
def test_trigger_workflow_version_returns_stable_unavailable_error(
|
||||
self,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()),
|
||||
)
|
||||
api = WorkflowRunByIdApi()
|
||||
handler = unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
handler(
|
||||
api,
|
||||
session=sqlite_session,
|
||||
app_model=_make_app_model(),
|
||||
end_user=_make_end_user(),
|
||||
workflow_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
def test_rejects_sandbox_plan_with_upgrade_error(
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
|
||||
@@ -11,11 +11,15 @@ from controllers.web.error import (
|
||||
NotWorkflowAppError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi
|
||||
from core.errors.error import ProviderTokenNotInitError, QuotaExceededError
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
|
||||
|
||||
def _workflow_app() -> App:
|
||||
@@ -68,6 +72,26 @@ class TestWorkflowRunApi:
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=TriggerWorkflowServiceModeUnavailableServiceError(),
|
||||
)
|
||||
@patch("controllers.web.workflow.web_ns")
|
||||
def test_trigger_workflow_returns_stable_unavailable_error(
|
||||
self,
|
||||
mock_ns: MagicMock,
|
||||
mock_gen: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_ns.payload = {"inputs": {}}
|
||||
|
||||
with app.test_request_context("/workflows/run", method="POST"):
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info:
|
||||
WorkflowRunApi().post(_workflow_app(), _end_user())
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
@patch(
|
||||
"controllers.web.workflow.AppGenerateService.generate",
|
||||
side_effect=QuotaExceededError(),
|
||||
|
||||
@@ -21,6 +21,7 @@ from core.app.apps.agent_app.app_generator import (
|
||||
AgentAppGenerator,
|
||||
AgentAppGeneratorError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
|
||||
@@ -427,6 +428,23 @@ class TestGenerateWorker:
|
||||
self._call(generator, mocker, queue_manager)
|
||||
assert queue_manager.publish_error.called
|
||||
|
||||
def test_session_configuration_change_is_published_without_unknown_error_log(
|
||||
self,
|
||||
generator: AgentAppGenerator,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
error = AgentSessionSnapshotIncompatibleError()
|
||||
self._wire(generator, mocker, run_side_effect=error)
|
||||
queue_manager = mocker.MagicMock()
|
||||
info_log = mocker.patch(f"{MODULE}.logger.info")
|
||||
exception_log = mocker.patch(f"{MODULE}.logger.exception")
|
||||
|
||||
self._call(generator, mocker, queue_manager)
|
||||
|
||||
queue_manager.publish_error.assert_called_once_with(error, module.PublishFrom.APPLICATION_MANAGER)
|
||||
info_log.assert_called_once()
|
||||
exception_log.assert_not_called()
|
||||
|
||||
|
||||
class TestResumeAfterFormSubmission:
|
||||
"""ENG-638: a resume turn re-sends the paused turn's original query so the
|
||||
|
||||
@@ -12,7 +12,8 @@ from typing import Any, override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
from dify_agent.protocol import (
|
||||
AgentRunUsage,
|
||||
@@ -52,7 +53,8 @@ from clients.agent_backend import (
|
||||
)
|
||||
from core.app.apps.agent_app import app_runner as app_runner_module
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
|
||||
@@ -628,6 +630,34 @@ def _dify_ctx() -> DifyRunContext:
|
||||
)
|
||||
|
||||
|
||||
def _compatible_session_snapshot() -> CompositorSessionSnapshot:
|
||||
request = (
|
||||
AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
.build(
|
||||
AgentAppRuntimeBuildContext(
|
||||
dify_context=_dify_ctx(),
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=_soul(),
|
||||
conversation_id="conv-1",
|
||||
user_query="hello",
|
||||
idempotency_key="msg-1",
|
||||
binding_id="binding-1",
|
||||
backend_binding_ref="backend-binding-1",
|
||||
)
|
||||
)
|
||||
.request
|
||||
)
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name=layer.name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})
|
||||
for layer in request.composition.layers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _runner(
|
||||
client: FakeAgentBackendRunClient,
|
||||
store: _FakeSessionStore,
|
||||
@@ -1314,7 +1344,7 @@ def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_di
|
||||
|
||||
|
||||
def test_prior_session_snapshot_is_threaded_into_request() -> None:
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
prior = _compatible_session_snapshot()
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
@@ -1325,8 +1355,23 @@ def test_prior_session_snapshot_is_threaded_into_request() -> None:
|
||||
assert client.request.session_snapshot is prior
|
||||
|
||||
|
||||
def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() -> None:
|
||||
compatible = _compatible_session_snapshot()
|
||||
stale = CompositorSessionSnapshot(
|
||||
layers=[layer for layer in compatible.layers if layer.name != "agent_soul_prompt"]
|
||||
)
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=stale)
|
||||
|
||||
with pytest.raises(AgentSessionSnapshotIncompatibleError, match="Start a new conversation"):
|
||||
_run(_runner(client, store), _FakeQueueManager())
|
||||
|
||||
assert client.request is None
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> None:
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
prior = _compatible_session_snapshot()
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
@@ -1599,7 +1644,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None:
|
||||
def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# ENG-638: a turn that runs while a pending form is answered threads the
|
||||
# human's reply into the request as deferred_tool_results.
|
||||
snapshot = CompositorSessionSnapshot(layers=[])
|
||||
snapshot = _compatible_session_snapshot()
|
||||
stored = StoredAgentAppSession(
|
||||
scope=AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
|
||||
@@ -6,6 +6,8 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
@@ -20,6 +22,7 @@ from clients.agent_backend import (
|
||||
AgentBackendRunRequestBuilder,
|
||||
)
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
@@ -163,6 +166,7 @@ def _ctx(
|
||||
*,
|
||||
query: str = "hello",
|
||||
agent_config_version_kind: str = "snapshot",
|
||||
session_snapshot: CompositorSessionSnapshot | None = None,
|
||||
) -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
@@ -182,6 +186,7 @@ def _ctx(
|
||||
binding_id="binding-1",
|
||||
backend_binding_ref="binding-ref-1",
|
||||
agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type]
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
|
||||
|
||||
@@ -198,6 +203,15 @@ def _soul_with_model() -> AgentSoulConfig:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_for_layer_names(layer_names: list[str]) -> CompositorSessionSnapshot:
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name=name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})
|
||||
for name in layer_names
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
@@ -236,6 +250,57 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
assert "credentials" not in result.redacted_request["composition"]["layers"][-1]["config"]
|
||||
assert result.metadata["conversation_id"] == "conv-1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("previous_prompt", "current_prompt"),
|
||||
[("", "You are Iris."), ("You are Iris.", "")],
|
||||
)
|
||||
def test_build_rejects_session_snapshot_after_layer_topology_changes(
|
||||
self,
|
||||
previous_prompt: str,
|
||||
current_prompt: str,
|
||||
) -> None:
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
previous_soul = _soul_with_model()
|
||||
previous_soul.prompt.system_prompt = previous_prompt
|
||||
previous_request = builder.build(_ctx(previous_soul, agent_config_version_kind="draft")).request
|
||||
snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers])
|
||||
current_soul = _soul_with_model()
|
||||
current_soul.prompt.system_prompt = current_prompt
|
||||
|
||||
with pytest.raises(AgentSessionSnapshotIncompatibleError) as exc_info:
|
||||
builder.build(
|
||||
_ctx(
|
||||
current_soul,
|
||||
agent_config_version_kind="draft",
|
||||
session_snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "agent_session_configuration_changed"
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "Start a new conversation" in str(exc_info.value)
|
||||
|
||||
def test_build_reuses_session_snapshot_when_config_changes_without_changing_layers(self) -> None:
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
previous_request = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft")).request
|
||||
snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers])
|
||||
current_soul = _soul_with_model()
|
||||
current_soul.prompt.system_prompt = "You are Ada."
|
||||
|
||||
result = builder.build(
|
||||
_ctx(
|
||||
current_soul,
|
||||
agent_config_version_kind="draft",
|
||||
session_snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.request.session_snapshot is snapshot
|
||||
|
||||
def test_build_wraps_agent_soul_prompt_for_build_draft(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
|
||||
@@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendRunFailedError
|
||||
from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueErrorEvent
|
||||
@@ -168,6 +169,17 @@ class TestBasedGenerateTaskPipeline:
|
||||
"message": "run limit reached (agent_run_id=run-1)",
|
||||
}
|
||||
|
||||
def test_stream_converter_preserves_agent_session_configuration_error(self):
|
||||
data = AppGenerateResponseConverter._error_to_stream_response(AgentSessionSnapshotIncompatibleError())
|
||||
|
||||
assert data == {
|
||||
"code": "agent_session_configuration_changed",
|
||||
"status": 409,
|
||||
"message": (
|
||||
"The Agent configuration changed after this conversation started. Start a new conversation to continue."
|
||||
),
|
||||
}
|
||||
|
||||
def test_handle_output_moderation_when_flagged(self, pipeline):
|
||||
handler = Mock()
|
||||
handler.moderation_completion.return_value = ("filtered", True)
|
||||
|
||||
@@ -22,6 +22,7 @@ from core.mcp.server.streamable_http import (
|
||||
)
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.errors.app import TriggerWorkflowServiceModeUnavailableError
|
||||
|
||||
|
||||
class TestHandleMCPRequest:
|
||||
@@ -157,6 +158,29 @@ class TestHandleMCPRequest:
|
||||
# Verify AppGenerateService was called
|
||||
mock_app_generate.generate.assert_called_once()
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_returns_trigger_workflow_business_error(self, mock_app_generate):
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_call_request.id = 123
|
||||
self.mock_request.root = mock_call_request
|
||||
mock_app_generate.generate.side_effect = TriggerWorkflowServiceModeUnavailableError()
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(),
|
||||
self.app,
|
||||
self.mock_request,
|
||||
self.user_input_form,
|
||||
self.mcp_server,
|
||||
self.end_user,
|
||||
123,
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCError)
|
||||
assert result.error.code == types.INVALID_REQUEST
|
||||
assert result.error.data == {"code": "trigger_workflow_service_mode_unavailable"}
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate):
|
||||
"""The negotiated version reaches handle_call_tool through the dispatcher."""
|
||||
|
||||
@@ -681,9 +681,16 @@ def test_trace_helpers_and_streaming_metrics(trace_environment: None) -> None:
|
||||
assert generated.message_id == "message-1"
|
||||
|
||||
|
||||
def test_trace_queue_collect_run_and_storage_boundary(monkeypatch: pytest.MonkeyPatch, trace_environment: None) -> None:
|
||||
monkeypatch.setattr(OpsTraceManager, "get_ops_trace_instance", classmethod(lambda cls, _app_id: True))
|
||||
manager = TraceQueueManager(app_id="app-id", user_id="user-1")
|
||||
def test_trace_queue_preserves_source_app_when_another_manager_drains(
|
||||
monkeypatch: pytest.MonkeyPatch, trace_environment: None
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
OpsTraceManager,
|
||||
"get_ops_trace_instance",
|
||||
classmethod(lambda cls, app_id: app_id == "source-app-id"),
|
||||
)
|
||||
draining_manager = TraceQueueManager(app_id="draining-app-id", user_id="user-1")
|
||||
source_manager = TraceQueueManager(app_id="source-app-id", user_id="user-2")
|
||||
task = TraceTask(
|
||||
trace_type=TraceTaskName.GENERATE_NAME_TRACE,
|
||||
conversation_id="conversation-1",
|
||||
@@ -692,8 +699,8 @@ def test_trace_queue_collect_run_and_storage_boundary(monkeypatch: pytest.Monkey
|
||||
generate_conversation_name="name",
|
||||
inputs="query",
|
||||
)
|
||||
manager.add_trace_task(task)
|
||||
assert manager.collect_tasks() == [task]
|
||||
source_manager.add_trace_task(task)
|
||||
assert draining_manager.collect_tasks() == [task]
|
||||
|
||||
recording_storage = RecordingStorage()
|
||||
dispatcher = RecordingDispatcher()
|
||||
@@ -701,14 +708,14 @@ def test_trace_queue_collect_run_and_storage_boundary(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr(module.process_trace_tasks, "apply_async", dispatcher.apply_async)
|
||||
file_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
monkeypatch.setattr(module, "uuid4", lambda: file_id)
|
||||
manager.add_trace_task(task)
|
||||
manager.run()
|
||||
source_manager.add_trace_task(task)
|
||||
draining_manager.run()
|
||||
|
||||
assert len(recording_storage.writes) == 1
|
||||
path, data = recording_storage.writes[0]
|
||||
assert path.endswith(f"app-id/{file_id.hex}.json")
|
||||
assert json.loads(data)["app_id"] == "app-id"
|
||||
assert dispatcher.payloads == [{"file_id": file_id.hex, "app_id": "app-id"}]
|
||||
assert path.endswith(f"source-app-id/{file_id.hex}.json")
|
||||
assert json.loads(data)["app_id"] == "source-app-id"
|
||||
assert dispatcher.payloads == [{"file_id": file_id.hex, "app_id": "source-app-id"}]
|
||||
|
||||
|
||||
def test_trace_queue_persists_with_caller_supplied_file_id(
|
||||
@@ -724,6 +731,7 @@ def test_trace_queue_persists_with_caller_supplied_file_id(
|
||||
generate_conversation_name="name",
|
||||
inputs="query",
|
||||
)
|
||||
task.app_id = "app-id"
|
||||
recording_storage = RecordingStorage()
|
||||
monkeypatch.setattr(module.storage, "save", recording_storage.save)
|
||||
|
||||
@@ -740,6 +748,7 @@ def test_trace_queue_persistence_error_propagates(monkeypatch: pytest.MonkeyPatc
|
||||
monkeypatch.setattr(OpsTraceManager, "get_ops_trace_instance", classmethod(lambda cls, _app_id: True))
|
||||
manager = TraceQueueManager(app_id="app-id", user_id="user-1")
|
||||
task = TraceTask(trace_type=TraceTaskName.GENERATE_NAME_TRACE)
|
||||
task.app_id = "app-id"
|
||||
|
||||
def fail_save(_path: str, _data: bytes) -> None:
|
||||
raise OSError("storage unavailable")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "migrations/versions/2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration_module() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("add_normalized_email_to_accounts", _MIGRATION_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load migration module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_upgrade_backfills_normalized_emails_without_enforcing_uniqueness() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
metadata = sa.MetaData()
|
||||
sa.Table(
|
||||
"accounts",
|
||||
metadata,
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO accounts (id, email) VALUES (:id, :email)"),
|
||||
[
|
||||
{"id": "account-1", "email": "User@Example.com"},
|
||||
{"id": "account-2", "email": "User@GoogleMail.com"},
|
||||
{"id": "account-3", "email": "u.ser+tag@gmail.com"},
|
||||
],
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
with engine.begin() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
original_op = module.__dict__["op"]
|
||||
module.__dict__["op"] = operations
|
||||
try:
|
||||
module.upgrade()
|
||||
finally:
|
||||
module.__dict__["op"] = original_op
|
||||
|
||||
with engine.begin() as connection:
|
||||
rows = connection.execute(sa.text("SELECT id, normalized_email FROM accounts ORDER BY id")).all()
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO accounts (id, email, normalized_email) VALUES (:id, :email, :normalized_email)"),
|
||||
{
|
||||
"id": "account-4",
|
||||
"email": "user@googlemail.com",
|
||||
"normalized_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
("account-1", "user@example.com"),
|
||||
("account-2", "user@gmail.com"),
|
||||
("account-3", "user@gmail.com"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dialect_name", "dialect_marker"),
|
||||
[("postgresql", "regexp_replace"), ("mysql", "SUBSTRING_INDEX"), ("sqlite", "instr")],
|
||||
)
|
||||
def test_backfill_emits_dialect_specific_offline_sql(dialect_name: str, dialect_marker: str) -> None:
|
||||
module = _load_migration_module()
|
||||
output = StringIO()
|
||||
migration_context = MigrationContext.configure(
|
||||
dialect_name=dialect_name,
|
||||
opts={"as_sql": True, "output_buffer": output},
|
||||
)
|
||||
operations = Operations(migration_context)
|
||||
original_op = module.__dict__["op"]
|
||||
module.__dict__["op"] = operations
|
||||
try:
|
||||
module._backfill_normalized_emails()
|
||||
finally:
|
||||
module.__dict__["op"] = original_op
|
||||
|
||||
generated_sql = output.getvalue()
|
||||
assert "UPDATE accounts" in generated_sql
|
||||
assert dialect_marker in generated_sql
|
||||
@@ -188,4 +188,5 @@ def test_account_repository_updates_email_and_removes_integrations_atomically(
|
||||
persisted = sqlite_session.get(Account, "account-1")
|
||||
assert persisted is not None
|
||||
assert persisted.email == "new@example.com"
|
||||
assert persisted.normalized_email == "new@example.com"
|
||||
assert sqlite_session.get(AccountIntegrate, integration_id) is None
|
||||
|
||||
@@ -953,16 +953,12 @@ class TestListOption:
|
||||
|
||||
class TestLegacyAgentManageKey:
|
||||
def test_legacy_agent_manage_key_membership(self):
|
||||
# Mirrors the builtin roles in the rbac service, which grant agent.manage
|
||||
# to owner/admin/editor only.
|
||||
# Preserve Agent access for every legacy role while external RBAC is disabled.
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_OWNER_KEYS,
|
||||
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
|
||||
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" in keys
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
|
||||
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" not in keys
|
||||
assert "agent.manage" in keys
|
||||
|
||||
19
api/tests/unit_tests/services/test_account_email.py
Normal file
19
api/tests/unit_tests/services/test_account_email.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from services.account_email import normalize_email
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email", "expected"),
|
||||
[
|
||||
("User@Example.com", "user@example.com"),
|
||||
("User@GoogleMail.com", "user@gmail.com"),
|
||||
("user@gmail.com", "user@gmail.com"),
|
||||
("u.ser+tag@gmail.com", "user@gmail.com"),
|
||||
("u.ser+tag@googlemail.com", "user@gmail.com"),
|
||||
("user+tag@example.com", "user+tag@example.com"),
|
||||
("u.ser+tag@example.org", "u.ser+tag@example.org"),
|
||||
],
|
||||
)
|
||||
def test_normalize_email(email: str, expected: str) -> None:
|
||||
assert normalize_email(email) == expected
|
||||
@@ -25,6 +25,7 @@ from services.account_service import AccountService, RegisterService, TenantServ
|
||||
from services.enterprise.rbac_service import MembersInRole, Paginated
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountLoginError,
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
@@ -135,6 +136,27 @@ class TestAccountService:
|
||||
|
||||
assert result is account
|
||||
|
||||
def test_authenticate_keeps_using_the_stored_email(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
mock_password_dependencies: _MockDependencies,
|
||||
) -> None:
|
||||
account = Account(
|
||||
name="Gmail User",
|
||||
email="u.ser+tag@gmail.com",
|
||||
normalized_email="user@gmail.com",
|
||||
password="hashed_password",
|
||||
password_salt="salt",
|
||||
)
|
||||
sqlite_session.add(account)
|
||||
sqlite_session.commit()
|
||||
|
||||
mock_password_dependencies["compare_password"].return_value = True
|
||||
|
||||
result = AccountService.authenticate("u.ser+tag@gmail.com", "password", session=sqlite_session)
|
||||
|
||||
assert result is account
|
||||
|
||||
def test_authenticate_account_not_found(self, sqlite_session: Session) -> None:
|
||||
"""Test authentication when account does not exist."""
|
||||
with pytest.raises(AccountPasswordError):
|
||||
@@ -230,6 +252,7 @@ class TestAccountService:
|
||||
account_id = result.id
|
||||
|
||||
assert result.email == "test@example.com"
|
||||
assert result.normalized_email == "test@example.com"
|
||||
assert result.name == "Test User"
|
||||
assert result.interface_language == "en-US"
|
||||
assert result.interface_theme == "light"
|
||||
@@ -242,6 +265,7 @@ class TestAccountService:
|
||||
persisted_account = assertion_session.get(Account, account_id)
|
||||
assert persisted_account is not None
|
||||
assert persisted_account.email == "test@example.com"
|
||||
assert persisted_account.normalized_email == "test@example.com"
|
||||
assert persisted_account.name == "Test User"
|
||||
assert persisted_account.interface_language == "en-US"
|
||||
assert persisted_account.interface_theme == "light"
|
||||
@@ -250,6 +274,40 @@ class TestAccountService:
|
||||
assert persisted_account.timezone == "America/New_York"
|
||||
assert persisted_account.last_login_ip == "203.0.113.10"
|
||||
|
||||
def test_create_account_rejects_normalized_email_only_when_requested(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
mock_external_service_dependencies: _MockDependencies,
|
||||
) -> None:
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
|
||||
sqlite_session.add(
|
||||
Account(
|
||||
name="Existing User",
|
||||
email="u.ser+existing@gmail.com",
|
||||
normalized_email="user@gmail.com",
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(AccountEmailAlreadyInUseError):
|
||||
AccountService.create_account(
|
||||
email="user@googlemail.com",
|
||||
name="New User",
|
||||
interface_language="en-US",
|
||||
check_normalized_email=True,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
duplicate = AccountService.create_account(
|
||||
email="user@googlemail.com",
|
||||
name="New User",
|
||||
interface_language="en-US",
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert duplicate.normalized_email == "user@gmail.com"
|
||||
|
||||
def test_create_account_uses_explicit_timezone(
|
||||
self,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
@@ -1547,6 +1605,7 @@ class TestRegisterService:
|
||||
password=None,
|
||||
timezone=None,
|
||||
ip_address="203.0.113.10",
|
||||
check_normalized_email=False,
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_create_workspace.assert_called_once_with(account=mock_account, session=sqlite_session)
|
||||
@@ -1664,10 +1723,35 @@ class TestRegisterService:
|
||||
is_setup=False,
|
||||
timezone=None,
|
||||
ip_address="203.0.113.10",
|
||||
check_normalized_email=True,
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_create_owner_tenant.assert_called_once_with(mock_account, session=sqlite_session)
|
||||
|
||||
def test_register_rejects_existing_normalized_email(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
mock_external_service_dependencies: _MockDependencies,
|
||||
) -> None:
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
sqlite_session.add(
|
||||
Account(
|
||||
name="Existing User",
|
||||
email="u.ser+existing@gmail.com",
|
||||
normalized_email="user@gmail.com",
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(AccountEmailAlreadyInUseError):
|
||||
RegisterService.register(
|
||||
email="user@googlemail.com",
|
||||
name="New User",
|
||||
language="en-US",
|
||||
create_workspace_required=False,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
def test_register_calls_default_workspace_join_for_enterprise_edition(
|
||||
self,
|
||||
sqlite_session: Session,
|
||||
@@ -2027,6 +2111,7 @@ class TestRegisterService:
|
||||
language="en-US",
|
||||
status=AccountStatus.PENDING,
|
||||
is_setup=True,
|
||||
check_normalized_email=True,
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_lookup.assert_called_once_with("newuser@example.com", session=sqlite_session)
|
||||
@@ -2073,6 +2158,7 @@ class TestRegisterService:
|
||||
language="en-US",
|
||||
status=AccountStatus.PENDING,
|
||||
is_setup=True,
|
||||
check_normalized_email=True,
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_lookup.assert_called_once_with(mixed_email, session=sqlite_session)
|
||||
|
||||
@@ -27,7 +27,11 @@ from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from enums import DeploymentEdition, QuotaType
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,10 +83,18 @@ def _make_user() -> MagicMock:
|
||||
return user
|
||||
|
||||
|
||||
def _make_workflow(*, workflow_id: str = "workflow-id", created_by: str = "owner-id") -> MagicMock:
|
||||
def _make_workflow(
|
||||
*,
|
||||
workflow_id: str = "workflow-id",
|
||||
created_by: str = "owner-id",
|
||||
node_types: tuple[str, ...] = (),
|
||||
) -> MagicMock:
|
||||
workflow = MagicMock()
|
||||
workflow.id = workflow_id
|
||||
workflow.created_by = created_by
|
||||
workflow.walk_nodes.return_value = [
|
||||
(f"node-{index}", {"type": node_type}) for index, node_type in enumerate(node_types)
|
||||
]
|
||||
return workflow
|
||||
|
||||
|
||||
@@ -467,6 +479,84 @@ class TestGenerate:
|
||||
assert call_kwargs.get("pause_state_config") is not None
|
||||
assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invoke_from",
|
||||
[InvokeFrom.OPENAPI, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP],
|
||||
)
|
||||
@pytest.mark.parametrize("node_type", ["trigger-plugin", "trigger-schedule", "trigger-webhook"])
|
||||
def test_trigger_workflow_rejects_manual_service_surfaces(
|
||||
self,
|
||||
invoke_from: InvokeFrom,
|
||||
node_type: str,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
workflow = _make_workflow(node_types=(node_type,))
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
generate = mocker.patch("services.app_generate_service.WorkflowAppGenerator.generate")
|
||||
|
||||
with pytest.raises(TriggerWorkflowServiceModeUnavailableError):
|
||||
AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.WORKFLOW),
|
||||
user=_make_user(),
|
||||
args={"inputs": {}},
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
generate.assert_not_called()
|
||||
|
||||
def test_trigger_workflow_allows_trigger_execution(self, mocker: MockerFixture) -> None:
|
||||
workflow = _make_workflow(node_types=("trigger-webhook",))
|
||||
mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
generate = mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.generate",
|
||||
return_value={"result": "trigger"},
|
||||
)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream",
|
||||
side_effect=lambda value: value,
|
||||
)
|
||||
|
||||
result = AppGenerateService.generate(
|
||||
app_model=_make_app(AppMode.WORKFLOW),
|
||||
user=_make_user(),
|
||||
args={"inputs": {}},
|
||||
invoke_from=InvokeFrom.TRIGGER,
|
||||
streaming=False,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
assert result == {"result": "trigger"}
|
||||
generate.assert_called_once()
|
||||
|
||||
def test_specific_start_workflow_version_remains_runnable(self, mocker: MockerFixture) -> None:
|
||||
workflow_id = str(uuid.uuid4())
|
||||
workflow = _make_workflow(workflow_id=workflow_id, node_types=("start",))
|
||||
get_workflow = mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.generate",
|
||||
return_value={"result": "version"},
|
||||
)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream",
|
||||
side_effect=lambda value: value,
|
||||
)
|
||||
app = _make_app(AppMode.WORKFLOW)
|
||||
session = MagicMock()
|
||||
|
||||
result = AppGenerateService.generate(
|
||||
app_model=app,
|
||||
user=_make_user(),
|
||||
args={"inputs": {}, "workflow_id": workflow_id},
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == {"result": "version"}
|
||||
get_workflow.assert_called_once_with(app, InvokeFrom.SERVICE_API, workflow_id, session=session)
|
||||
|
||||
# -- WORKFLOW streaming -------------------------------------------------
|
||||
def test_workflow_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]):
|
||||
config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams")
|
||||
|
||||
@@ -11,8 +11,14 @@ DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
# Shutdown and retention
|
||||
# Seconds to wait for active local runs during graceful shutdown before cancellation.
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
# Seconds to retain Redis run records and per-run event streams (default: 3 days).
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
# Seconds to retain Redis run records and per-run event streams after the last write (default: 2 hours).
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=7200
|
||||
# Approximate target maximum for replayable events retained in each per-run Redis Stream.
|
||||
DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000
|
||||
# Coalesce compatible text deltas for at most this many milliseconds before publishing.
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100
|
||||
# Flush a text-delta batch immediately once it reaches this many characters.
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096
|
||||
|
||||
# Plugin daemon
|
||||
# Base URL for the Dify plugin daemon used by local runs.
|
||||
|
||||
@@ -34,7 +34,10 @@ also reads `.env` and `dify-agent/.env` when present.
|
||||
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |
|
||||
| `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. |
|
||||
| `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. |
|
||||
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. |
|
||||
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `7200` | Seconds to retain Redis run records and per-run event streams after their last write; defaults to 2 hours. |
|
||||
| `DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH` | `5000` | Approximate target maximum for replayable events retained in each per-run Redis Stream. |
|
||||
| `DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS` | `100` | Maximum time to buffer compatible text deltas before publishing one coalesced event. |
|
||||
| `DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS` | `4096` | Character threshold that flushes a buffered text-delta event immediately. |
|
||||
| `DIFY_AGENT_RUN_TIMEOUT_SECONDS` | `3600` | Wall-clock deadline in seconds for the Pydantic AI `agent.run(...)` model/tool loop. Deadline failures use `agent_run_limit_exceeded`. Its default intentionally matches `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS`, but the settings are independently configurable. |
|
||||
| `DIFY_AGENT_BINDING_FILE_DOWNLOAD_COMMAND_TIMEOUT_SECONDS` | `210` | Shell command deadline for running the sandbox `dify-agent file upload --no-download-link` conversion. Keep it above the CLI's 180-second upload deadline. |
|
||||
| `DIFY_AGENT_API_TOKEN` | empty | Optional Bearer token required by private run, Execution Binding, Home Snapshot, and Binding file control-plane routes. Must match Dify API `AGENT_BACKEND_API_TOKEN`. |
|
||||
@@ -75,7 +78,10 @@ Example `.env`:
|
||||
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent-dev
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=7200
|
||||
DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096
|
||||
DIFY_AGENT_RUN_TIMEOUT_SECONDS=3600
|
||||
DIFY_AGENT_API_TOKEN=replace-with-agent-backend-token
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
@@ -138,7 +144,13 @@ and lifecycle contract.
|
||||
|
||||
Run records and event streams use the same retention. Status writes refresh the
|
||||
record TTL, and event writes refresh both the stream TTL and the corresponding
|
||||
record TTL so active runs that keep producing events remain observable.
|
||||
record TTL so active runs that keep producing events remain observable. Text
|
||||
deltas for the same response part and provider metadata are coalesced within a
|
||||
bounded time/size window before being published. Every Redis Stream write also
|
||||
applies the configured approximate maximum length. A reconnect cursor older than the
|
||||
retained window resumes from the oldest remaining event, so callers must treat
|
||||
the terminal snapshot and application-owned history as authoritative rather
|
||||
than relying on the Agent event stream as long-term history.
|
||||
|
||||
## Validate the E2B Compose deployment
|
||||
|
||||
|
||||
137
dify-agent/src/dify_agent/runtime/event_coalescer.py
Normal file
137
dify-agent/src/dify_agent/runtime/event_coalescer.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Bound high-frequency text deltas before they reach the shared event sink."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import replace
|
||||
|
||||
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, TextPartDelta
|
||||
|
||||
|
||||
DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS = 0.1
|
||||
DEFAULT_TEXT_DELTA_MAX_CHARS = 4096
|
||||
|
||||
|
||||
async def coalesce_agent_stream_events(
|
||||
events: AsyncIterable[AgentStreamEvent],
|
||||
*,
|
||||
flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
) -> AsyncIterator[AgentStreamEvent]:
|
||||
"""Merge compatible text deltas without delaying them past the configured interval.
|
||||
|
||||
One source event is read ahead while a text delta is buffered. Non-text events
|
||||
flush the buffer first, preserving the public Pydantic AI event ordering. The
|
||||
timer starts with the first buffered delta instead of sliding on every token,
|
||||
so a continuously streaming model cannot postpone delivery indefinitely.
|
||||
"""
|
||||
if flush_interval_seconds < 0:
|
||||
raise ValueError("flush_interval_seconds must be non-negative")
|
||||
if max_chars <= 0:
|
||||
raise ValueError("max_chars must be positive")
|
||||
|
||||
iterator = aiter(events)
|
||||
next_event_task: asyncio.Future[AgentStreamEvent] | None = asyncio.ensure_future(anext(iterator))
|
||||
buffered: PartDeltaEvent | None = None
|
||||
flush_deadline: float | None = None
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
while next_event_task is not None:
|
||||
timeout = None if flush_deadline is None else max(0.0, flush_deadline - loop.time())
|
||||
try:
|
||||
done, _pending = await asyncio.wait((next_event_task,), timeout=timeout)
|
||||
except asyncio.CancelledError:
|
||||
if buffered is not None:
|
||||
yield buffered
|
||||
raise
|
||||
|
||||
if not done:
|
||||
assert buffered is not None
|
||||
yield buffered
|
||||
buffered = None
|
||||
flush_deadline = None
|
||||
continue
|
||||
|
||||
try:
|
||||
event = next_event_task.result()
|
||||
except StopAsyncIteration:
|
||||
next_event_task = None
|
||||
if buffered is not None:
|
||||
yield buffered
|
||||
return
|
||||
except BaseException:
|
||||
next_event_task = None
|
||||
if buffered is not None:
|
||||
yield buffered
|
||||
raise
|
||||
|
||||
next_event_task = asyncio.ensure_future(anext(iterator))
|
||||
text_delta = _text_delta(event)
|
||||
if text_delta is None:
|
||||
if buffered is not None:
|
||||
yield buffered
|
||||
buffered = None
|
||||
flush_deadline = None
|
||||
yield event
|
||||
continue
|
||||
|
||||
if buffered is not None and _can_merge(buffered, event):
|
||||
buffered = _merge_text_deltas(buffered, event)
|
||||
else:
|
||||
if buffered is not None:
|
||||
yield buffered
|
||||
assert isinstance(event, PartDeltaEvent)
|
||||
buffered = event
|
||||
flush_deadline = loop.time() + flush_interval_seconds
|
||||
|
||||
buffered_delta = _text_delta(buffered)
|
||||
assert buffered_delta is not None
|
||||
if len(buffered_delta.content_delta) >= max_chars:
|
||||
yield buffered
|
||||
buffered = None
|
||||
flush_deadline = None
|
||||
finally:
|
||||
if next_event_task is not None and not next_event_task.done():
|
||||
next_event_task.cancel()
|
||||
with suppress(asyncio.CancelledError, StopAsyncIteration):
|
||||
_ = await next_event_task
|
||||
|
||||
|
||||
def _text_delta(event: AgentStreamEvent) -> TextPartDelta | None:
|
||||
if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
|
||||
return event.delta
|
||||
return None
|
||||
|
||||
|
||||
def _can_merge(left: PartDeltaEvent, right: AgentStreamEvent) -> bool:
|
||||
right_delta = _text_delta(right)
|
||||
if right_delta is None:
|
||||
return False
|
||||
left_delta = left.delta
|
||||
assert isinstance(left_delta, TextPartDelta)
|
||||
assert isinstance(right, PartDeltaEvent)
|
||||
return (
|
||||
left.index == right.index
|
||||
and left_delta.provider_name == right_delta.provider_name
|
||||
and left_delta.provider_details == right_delta.provider_details
|
||||
)
|
||||
|
||||
|
||||
def _merge_text_deltas(left: PartDeltaEvent, right: AgentStreamEvent) -> PartDeltaEvent:
|
||||
left_delta = left.delta
|
||||
right_delta = _text_delta(right)
|
||||
assert isinstance(left_delta, TextPartDelta)
|
||||
assert right_delta is not None
|
||||
merged_delta = replace(
|
||||
left_delta,
|
||||
content_delta=left_delta.content_delta + right_delta.content_delta,
|
||||
)
|
||||
return replace(left, delta=merged_delta)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS",
|
||||
"DEFAULT_TEXT_DELTA_MAX_CHARS",
|
||||
"coalesce_agent_stream_events",
|
||||
]
|
||||
@@ -24,6 +24,10 @@ from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
|
||||
from dify_agent.protocol.schemas import AgentRunUsage, CancelRunRequest, CancelRunResponse, CreateRunRequest, RunStatus
|
||||
from dify_agent.runtime.cancellation import RunCancellationIntent
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.event_coalescer import (
|
||||
DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
)
|
||||
from dify_agent.runtime.event_sink import RunEventSink, RunFinalizationResult, emit_run_failed
|
||||
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, AgentRunRunner
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
@@ -106,6 +110,8 @@ class RunScheduler:
|
||||
store: RunStore
|
||||
shutdown_grace_seconds: float
|
||||
run_timeout_seconds: float
|
||||
stream_text_delta_flush_interval_seconds: float
|
||||
stream_text_delta_max_chars: int
|
||||
active_tasks: dict[str, asyncio.Task[None]]
|
||||
stopping: bool
|
||||
runner_factory: RunRunnerFactory | None
|
||||
@@ -122,12 +128,16 @@ class RunScheduler:
|
||||
dify_api_http_client: httpx.AsyncClient,
|
||||
shutdown_grace_seconds: float = 30,
|
||||
run_timeout_seconds: float = DEFAULT_AGENT_RUN_TIMEOUT_SECONDS,
|
||||
stream_text_delta_flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
runner_factory: RunRunnerFactory | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.shutdown_grace_seconds = shutdown_grace_seconds
|
||||
self.run_timeout_seconds = run_timeout_seconds
|
||||
self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds
|
||||
self.stream_text_delta_max_chars = stream_text_delta_max_chars
|
||||
self.active_tasks = {}
|
||||
self.stopping = False
|
||||
self.plugin_daemon_http_client = plugin_daemon_http_client
|
||||
@@ -304,6 +314,8 @@ class RunScheduler:
|
||||
layer_providers=self.layer_providers,
|
||||
is_cancelled=is_cancelled,
|
||||
run_timeout_seconds=self.run_timeout_seconds,
|
||||
stream_text_delta_flush_interval_seconds=self.stream_text_delta_flush_interval_seconds,
|
||||
stream_text_delta_max_chars=self.stream_text_delta_max_chars,
|
||||
)
|
||||
|
||||
def _discard_active_run(self, run_id: str) -> None:
|
||||
|
||||
@@ -7,8 +7,9 @@ policy is validated:
|
||||
- model runs: enter a fresh ``CompositorRun`` (or resume one from a snapshot),
|
||||
pass the current Dify system prompts as run-level instructions, run
|
||||
pydantic-ai with either the current ``run.user_prompts`` or deferred external
|
||||
tool results, emit raw stream events with agent-message delta annotations, apply
|
||||
request-level ``on_exit`` signals, and publish a terminal success or failure event;
|
||||
tool results, emit stream events with bounded text-delta coalescing and
|
||||
agent-message annotations, apply request-level ``on_exit`` signals, and publish
|
||||
a terminal success or failure event;
|
||||
The Pydantic AI model is resolved from the active Agenton layer named by
|
||||
``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored
|
||||
message history only through session state. Once pydantic-ai binds and builds
|
||||
@@ -67,6 +68,11 @@ from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_ru
|
||||
from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers
|
||||
from dify_agent.runtime.compaction import build_compaction_capability
|
||||
from dify_agent.runtime_backend import BindingLostError
|
||||
from dify_agent.runtime.event_coalescer import (
|
||||
DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
coalesce_agent_stream_events,
|
||||
)
|
||||
from dify_agent.runtime.event_sink import (
|
||||
RunEventSink,
|
||||
emit_pydantic_ai_event,
|
||||
@@ -187,6 +193,8 @@ class AgentRunRunner:
|
||||
dify_api_http_client: httpx.AsyncClient
|
||||
is_cancelled: Callable[[], bool]
|
||||
run_timeout_seconds: float
|
||||
stream_text_delta_flush_interval_seconds: float
|
||||
stream_text_delta_max_chars: int
|
||||
_terminal_session_snapshot: CompositorSessionSnapshot | None
|
||||
_terminal_usage: AgentRunUsage | None
|
||||
|
||||
@@ -201,7 +209,13 @@ class AgentRunRunner:
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
run_timeout_seconds: float = DEFAULT_AGENT_RUN_TIMEOUT_SECONDS,
|
||||
stream_text_delta_flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
) -> None:
|
||||
if stream_text_delta_flush_interval_seconds < 0:
|
||||
raise ValueError("stream_text_delta_flush_interval_seconds must be non-negative")
|
||||
if stream_text_delta_max_chars <= 0:
|
||||
raise ValueError("stream_text_delta_max_chars must be positive")
|
||||
self.sink = sink
|
||||
self.request = request
|
||||
self.run_id = run_id
|
||||
@@ -210,6 +224,8 @@ class AgentRunRunner:
|
||||
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
self.is_cancelled = is_cancelled or (lambda: False)
|
||||
self.run_timeout_seconds = run_timeout_seconds
|
||||
self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds
|
||||
self.stream_text_delta_max_chars = stream_text_delta_max_chars
|
||||
self._terminal_session_snapshot = None
|
||||
self._terminal_usage = None
|
||||
|
||||
@@ -320,7 +336,12 @@ class AgentRunRunner:
|
||||
raise AgentRunValidationError(EMPTY_USER_PROMPTS_ERROR)
|
||||
|
||||
async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
coalesced_events = coalesce_agent_stream_events(
|
||||
events,
|
||||
flush_interval_seconds=self.stream_text_delta_flush_interval_seconds,
|
||||
max_chars=self.stream_text_delta_max_chars,
|
||||
)
|
||||
async for event in coalesced_events:
|
||||
if self.is_cancelled():
|
||||
raise asyncio.CancelledError
|
||||
text_delta = _extract_agent_message_delta(event)
|
||||
|
||||
@@ -105,6 +105,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
redis,
|
||||
prefix=resolved_settings.redis_prefix,
|
||||
run_retention_seconds=resolved_settings.run_retention_seconds,
|
||||
run_event_stream_max_length=resolved_settings.run_event_stream_max_length,
|
||||
)
|
||||
scheduler = RunScheduler(
|
||||
store=store,
|
||||
@@ -112,6 +113,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
dify_api_http_client=dify_api_inner_http_client,
|
||||
shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds,
|
||||
run_timeout_seconds=resolved_settings.run_timeout_seconds,
|
||||
stream_text_delta_flush_interval_seconds=(resolved_settings.stream_text_delta_flush_interval_ms / 1000),
|
||||
stream_text_delta_max_chars=resolved_settings.stream_text_delta_max_chars,
|
||||
layer_providers=layer_providers,
|
||||
)
|
||||
state["store"] = store
|
||||
|
||||
@@ -21,6 +21,10 @@ from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_b
|
||||
from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler
|
||||
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key
|
||||
from dify_agent.runtime.event_coalescer import (
|
||||
DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
)
|
||||
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
|
||||
from dify_agent.runtime_backend import RuntimeBackendProfile
|
||||
from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS
|
||||
@@ -32,7 +36,8 @@ from dify_agent.runtime_backend.profile import (
|
||||
create_runtime_backend_profile,
|
||||
)
|
||||
|
||||
DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60
|
||||
DEFAULT_RUN_RETENTION_SECONDS = 2 * 60 * 60
|
||||
DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH = 5000
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
@@ -42,6 +47,12 @@ class ServerSettings(BaseSettings):
|
||||
redis_prefix: str = "dify-agent"
|
||||
shutdown_grace_seconds: float = 30
|
||||
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
|
||||
run_event_stream_max_length: int = Field(default=DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, ge=1)
|
||||
stream_text_delta_flush_interval_ms: int = Field(
|
||||
default=int(DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS * 1000),
|
||||
ge=0,
|
||||
)
|
||||
stream_text_delta_max_chars: int = Field(default=DEFAULT_TEXT_DELTA_MAX_CHARS, ge=1)
|
||||
run_timeout_seconds: float = Field(default=DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, gt=0)
|
||||
plugin_daemon_url: str = "http://localhost:5002"
|
||||
plugin_daemon_api_key: str = ""
|
||||
@@ -255,4 +266,4 @@ class ServerSettings(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"]
|
||||
__all__ = ["DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH", "DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"]
|
||||
|
||||
@@ -35,7 +35,7 @@ from dify_agent.runtime.event_sink import (
|
||||
terminal_event_status_fields,
|
||||
)
|
||||
from dify_agent.server.schemas import RunRecord, new_run_id
|
||||
from dify_agent.server.settings import DEFAULT_RUN_RETENTION_SECONDS
|
||||
from dify_agent.server.settings import DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, DEFAULT_RUN_RETENTION_SECONDS
|
||||
from dify_agent.storage.redis_keys import run_cancel_intent_key, run_events_key, run_record_key
|
||||
|
||||
_TERMINAL_RUN_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"}
|
||||
@@ -75,7 +75,7 @@ end
|
||||
|
||||
local ttl = tonumber(ARGV[8])
|
||||
local updated_record_json = cjson.encode(record)
|
||||
local event_id = redis.call("XADD", KEYS[2], "*", "payload", ARGV[7])
|
||||
local event_id = redis.call("XADD", KEYS[2], "MAXLEN", "~", ARGV[9], "*", "payload", ARGV[7])
|
||||
redis.call("EXPIRE", KEYS[2], ttl)
|
||||
redis.call("SET", KEYS[1], updated_record_json, "EX", ttl)
|
||||
return {1, ARGV[1], event_id}
|
||||
@@ -100,7 +100,7 @@ if redis.call("EXISTS", KEYS[2]) == 1 then
|
||||
end
|
||||
|
||||
local ttl = tonumber(ARGV[2])
|
||||
redis.call("XADD", KEYS[2], "*", "payload", ARGV[1])
|
||||
redis.call("XADD", KEYS[2], "MAXLEN", "1", "*", "payload", ARGV[1])
|
||||
redis.call("EXPIRE", KEYS[2], ttl)
|
||||
redis.call("EXPIRE", KEYS[1], ttl)
|
||||
redis.call("EXPIRE", KEYS[3], ttl)
|
||||
@@ -135,7 +135,7 @@ end
|
||||
record.error_type = cjson.null
|
||||
|
||||
local ttl = tonumber(ARGV[5])
|
||||
local event_id = redis.call("XADD", KEYS[3], "*", "payload", ARGV[4])
|
||||
local event_id = redis.call("XADD", KEYS[3], "MAXLEN", "~", ARGV[6], "*", "payload", ARGV[4])
|
||||
redis.call("DEL", KEYS[2])
|
||||
redis.call("EXPIRE", KEYS[3], ttl)
|
||||
redis.call("SET", KEYS[1], cjson.encode(record), "EX", ttl)
|
||||
@@ -147,16 +147,19 @@ class RedisRunStore(RunEventSink):
|
||||
"""Async Redis implementation for run records and event logs.
|
||||
|
||||
``run_retention_seconds`` is applied to both the run record key and the
|
||||
per-run Redis stream. Event writes run ``XADD`` and both TTL refreshes in one
|
||||
Redis transaction so a newly created stream is not left without expiration if
|
||||
the client is interrupted between commands. Event writes also refresh the
|
||||
record TTL so long-running runs that keep producing events do not lose their
|
||||
status record mid-run.
|
||||
per-run Redis stream. Every public stream write applies an approximate
|
||||
maximum length before refreshing both TTLs, so active runs cannot grow one
|
||||
Redis key without bound without paying the CPU cost of exact per-entry
|
||||
trimming. The transaction also ensures a newly created stream is not left
|
||||
without expiration if the client is interrupted between commands.
|
||||
Event writes refresh the record TTL so long-running runs that keep producing
|
||||
events do not lose their status record mid-run.
|
||||
"""
|
||||
|
||||
redis: Redis
|
||||
prefix: str
|
||||
run_retention_seconds: int
|
||||
run_event_stream_max_length: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -164,12 +167,16 @@ class RedisRunStore(RunEventSink):
|
||||
*,
|
||||
prefix: str = "dify-agent",
|
||||
run_retention_seconds: int = DEFAULT_RUN_RETENTION_SECONDS,
|
||||
run_event_stream_max_length: int = DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH,
|
||||
) -> None:
|
||||
if run_retention_seconds <= 0:
|
||||
raise ValueError("run_retention_seconds must be positive")
|
||||
if run_event_stream_max_length <= 0:
|
||||
raise ValueError("run_event_stream_max_length must be positive")
|
||||
self.redis = redis
|
||||
self.prefix = prefix
|
||||
self.run_retention_seconds = run_retention_seconds
|
||||
self.run_event_stream_max_length = run_event_stream_max_length
|
||||
|
||||
async def create_run(self) -> RunRecord:
|
||||
"""Persist a running run record without storing the create request."""
|
||||
@@ -199,6 +206,8 @@ class RedisRunStore(RunEventSink):
|
||||
_ = pipeline.xadd(
|
||||
events_key,
|
||||
{"payload": payload},
|
||||
maxlen=self.run_event_stream_max_length,
|
||||
approximate=True,
|
||||
)
|
||||
_ = pipeline.expire(events_key, self.run_retention_seconds)
|
||||
_ = pipeline.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds)
|
||||
@@ -226,6 +235,7 @@ class RedisRunStore(RunEventSink):
|
||||
error_type.value if error_type is not None else "",
|
||||
payload,
|
||||
str(self.run_retention_seconds),
|
||||
str(self.run_event_stream_max_length),
|
||||
),
|
||||
)
|
||||
raw_result = await evaluation
|
||||
@@ -316,6 +326,7 @@ class RedisRunStore(RunEventSink):
|
||||
error or "",
|
||||
payload,
|
||||
str(self.run_retention_seconds),
|
||||
str(self.run_event_stream_max_length),
|
||||
),
|
||||
)
|
||||
result = cast(list[object], await evaluation)
|
||||
@@ -385,4 +396,9 @@ def _decode_redis_text(value: object) -> str:
|
||||
return value.decode() if isinstance(value, bytes) else str(value)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "RedisRunStore", "RunNotFoundError"]
|
||||
__all__ = [
|
||||
"DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH",
|
||||
"DEFAULT_RUN_RETENTION_SECONDS",
|
||||
"RedisRunStore",
|
||||
"RunNotFoundError",
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ async def _run(lease, script: str, *, cwd: str) -> str:
|
||||
env={"HOME": lease.layout.home_dir},
|
||||
timeout=30.0,
|
||||
max_output_bytes=4096,
|
||||
mode="stdio",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.output_complete
|
||||
|
||||
@@ -153,6 +153,50 @@ def test_success_and_cancel_intent_commit_exactly_one_matching_terminal(redis_ur
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_event_stream_is_trimmed_and_keeps_the_terminal_event(redis_url: str) -> None:
|
||||
async def scenario() -> None:
|
||||
client = Redis.from_url(redis_url)
|
||||
prefix = f"bounded-events-{uuid4().hex}"
|
||||
max_length = 100
|
||||
store = RedisRunStore(
|
||||
client,
|
||||
prefix=prefix,
|
||||
run_retention_seconds=60,
|
||||
run_event_stream_max_length=max_length,
|
||||
)
|
||||
run_id: str | None = None
|
||||
try:
|
||||
record = await store.create_run()
|
||||
run_id = record.run_id
|
||||
for _ in range(1000):
|
||||
_ = await store.append_event(RunStartedEvent(run_id=record.run_id))
|
||||
result = await store.finalize_run(
|
||||
RunSucceededEvent(
|
||||
run_id=record.run_id,
|
||||
data=RunSucceededEventData(
|
||||
output="done",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.applied is True
|
||||
stream_length = await client.xlen(run_events_key(prefix, record.run_id))
|
||||
assert max_length <= stream_length <= max_length * 2
|
||||
events = await store.get_events(record.run_id, limit=max_length * 2)
|
||||
assert events.events[-1].type == "run_succeeded"
|
||||
finally:
|
||||
if run_id is not None:
|
||||
await client.delete(
|
||||
run_record_key(prefix, run_id),
|
||||
run_events_key(prefix, run_id),
|
||||
run_cancel_intent_key(prefix, run_id),
|
||||
)
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal_status", ["succeeded", "failed"])
|
||||
def test_terminal_first_rejects_late_cancellation(redis_url: str, terminal_status: str) -> None:
|
||||
async def scenario() -> None:
|
||||
@@ -303,6 +347,10 @@ def test_non_owner_scheduler_cancellation_stops_owner_runner(redis_url: str) ->
|
||||
def terminal_session_snapshot(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def terminal_usage(self) -> None:
|
||||
return None
|
||||
|
||||
async def run(self) -> None:
|
||||
self.started.set()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartEndEvent, TextPart, TextPartDelta
|
||||
|
||||
from dify_agent.runtime.event_coalescer import coalesce_agent_stream_events
|
||||
|
||||
|
||||
def _delta(content: str, *, index: int = 0, provider_details: dict[str, Any] | None = None) -> PartDeltaEvent:
|
||||
return PartDeltaEvent(
|
||||
index=index,
|
||||
delta=TextPartDelta(content, provider_name="test", provider_details=provider_details),
|
||||
)
|
||||
|
||||
|
||||
def _part_end(content: str = "done") -> PartEndEvent:
|
||||
return PartEndEvent(index=0, part=TextPart(content))
|
||||
|
||||
|
||||
async def _events(*events: PartDeltaEvent | PartEndEvent) -> AsyncIterator[PartDeltaEvent | PartEndEvent]:
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
|
||||
async def _collect(events: AsyncIterator[AgentStreamEvent]) -> list[AgentStreamEvent]:
|
||||
return [event async for event in events]
|
||||
|
||||
|
||||
def _delta_content(event: AgentStreamEvent) -> str:
|
||||
assert isinstance(event, PartDeltaEvent)
|
||||
assert isinstance(event.delta, TextPartDelta)
|
||||
return event.delta.content_delta
|
||||
|
||||
|
||||
def test_coalesces_compatible_text_deltas_and_preserves_non_text_order() -> None:
|
||||
async def scenario() -> list[AgentStreamEvent]:
|
||||
return await _collect(
|
||||
coalesce_agent_stream_events(
|
||||
_events(_delta("hel"), _delta("lo"), _part_end()),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=100,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(scenario())
|
||||
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], PartDeltaEvent)
|
||||
assert isinstance(result[0].delta, TextPartDelta)
|
||||
assert result[0].delta.content_delta == "hello"
|
||||
assert isinstance(result[1], PartEndEvent)
|
||||
|
||||
|
||||
def test_does_not_merge_different_parts_or_provider_details() -> None:
|
||||
async def scenario() -> list[AgentStreamEvent]:
|
||||
return await _collect(
|
||||
coalesce_agent_stream_events(
|
||||
_events(
|
||||
_delta("a", index=0),
|
||||
_delta("b", index=1),
|
||||
_delta("c", index=1, provider_details={"token": 1}),
|
||||
),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=100,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(scenario())
|
||||
|
||||
assert [_delta_content(event) for event in result] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_flushes_when_character_limit_is_reached() -> None:
|
||||
async def scenario() -> list[AgentStreamEvent]:
|
||||
return await _collect(
|
||||
coalesce_agent_stream_events(
|
||||
_events(_delta("ab"), _delta("cd"), _delta("ef")),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=4,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(scenario())
|
||||
|
||||
assert [_delta_content(event) for event in result] == ["abcd", "ef"]
|
||||
|
||||
|
||||
def test_high_volume_text_deltas_are_reduced_to_size_bounded_batches() -> None:
|
||||
async def scenario() -> list[AgentStreamEvent]:
|
||||
async def many_deltas() -> AsyncIterator[AgentStreamEvent]:
|
||||
for _ in range(20_000):
|
||||
yield _delta("x")
|
||||
|
||||
return await _collect(
|
||||
coalesce_agent_stream_events(
|
||||
many_deltas(),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=4096,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(scenario())
|
||||
|
||||
assert len(result) == 5
|
||||
assert sum(len(_delta_content(event)) for event in result) == 20_000
|
||||
|
||||
|
||||
def test_flushes_on_deadline_while_source_is_idle() -> None:
|
||||
async def scenario() -> tuple[PartDeltaEvent, PartEndEvent]:
|
||||
release_source = asyncio.Event()
|
||||
|
||||
async def delayed_events() -> AsyncIterator[PartDeltaEvent | PartEndEvent]:
|
||||
yield _delta("ready")
|
||||
await release_source.wait()
|
||||
yield _part_end()
|
||||
|
||||
events = coalesce_agent_stream_events(
|
||||
delayed_events(),
|
||||
flush_interval_seconds=0.01,
|
||||
max_chars=100,
|
||||
)
|
||||
first = await asyncio.wait_for(anext(events), timeout=0.2)
|
||||
release_source.set()
|
||||
second = await asyncio.wait_for(anext(events), timeout=0.2)
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
_ = await anext(events)
|
||||
assert isinstance(first, PartDeltaEvent)
|
||||
assert isinstance(second, PartEndEvent)
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(scenario())
|
||||
|
||||
assert isinstance(first.delta, TextPartDelta)
|
||||
assert first.delta.content_delta == "ready"
|
||||
assert isinstance(second.part, TextPart)
|
||||
assert second.part.content == "done"
|
||||
|
||||
|
||||
def test_flushes_buffer_before_propagating_source_failure() -> None:
|
||||
async def scenario() -> PartDeltaEvent:
|
||||
async def failing_events() -> AsyncIterator[AgentStreamEvent]:
|
||||
yield _delta("partial")
|
||||
raise RuntimeError("stream failed")
|
||||
|
||||
events = coalesce_agent_stream_events(
|
||||
failing_events(),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=100,
|
||||
)
|
||||
first = await anext(events)
|
||||
with pytest.raises(RuntimeError, match="stream failed"):
|
||||
_ = await anext(events)
|
||||
assert isinstance(first, PartDeltaEvent)
|
||||
return first
|
||||
|
||||
first = asyncio.run(scenario())
|
||||
|
||||
assert isinstance(first.delta, TextPartDelta)
|
||||
assert first.delta.content_delta == "partial"
|
||||
|
||||
|
||||
def test_flushes_buffer_before_consumer_cancellation() -> None:
|
||||
async def scenario() -> list[AgentStreamEvent]:
|
||||
source_waiting = asyncio.Event()
|
||||
emitted: list[AgentStreamEvent] = []
|
||||
|
||||
async def blocked_events() -> AsyncIterator[AgentStreamEvent]:
|
||||
yield _delta("partial")
|
||||
source_waiting.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def consume() -> None:
|
||||
async for event in coalesce_agent_stream_events(
|
||||
blocked_events(),
|
||||
flush_interval_seconds=10,
|
||||
max_chars=100,
|
||||
):
|
||||
emitted.append(event)
|
||||
|
||||
task = asyncio.create_task(consume())
|
||||
await asyncio.wait_for(source_waiting.wait(), timeout=0.2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
return emitted
|
||||
|
||||
emitted = asyncio.run(scenario())
|
||||
|
||||
assert len(emitted) == 1
|
||||
assert _delta_content(emitted[0]) == "partial"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flush_interval_seconds", "max_chars", "message"),
|
||||
[(-0.1, 100, "non-negative"), (0.1, 0, "positive")],
|
||||
)
|
||||
def test_rejects_invalid_bounds(flush_interval_seconds: float, max_chars: int, message: str) -> None:
|
||||
async def scenario() -> None:
|
||||
events = coalesce_agent_stream_events(
|
||||
_events(_delta("a")),
|
||||
flush_interval_seconds=flush_interval_seconds,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_ = await anext(events)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -461,7 +461,7 @@ class FinalizeSuccessOnCancellationRunner(SnapshotlessRunner):
|
||||
assert result.applied is True
|
||||
|
||||
|
||||
def test_default_runner_factory_passes_run_timeout_to_runner() -> None:
|
||||
def test_default_runner_factory_passes_runtime_limits_to_runner() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
record = await store.create_run()
|
||||
@@ -471,12 +471,16 @@ def test_default_runner_factory_passes_run_timeout_to_runner() -> None:
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
run_timeout_seconds=17,
|
||||
stream_text_delta_flush_interval_seconds=0.25,
|
||||
stream_text_delta_max_chars=2048,
|
||||
)
|
||||
|
||||
runner = scheduler._default_runner_factory(record, _request(), is_cancelled=lambda: False)
|
||||
|
||||
assert isinstance(runner, AgentRunRunner)
|
||||
assert runner.run_timeout_seconds == 17
|
||||
assert runner.stream_text_delta_flush_interval_seconds == 0.25
|
||||
assert runner.stream_text_delta_max_chars == 2048
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ class FakeRunScheduler:
|
||||
store: object
|
||||
shutdown_grace_seconds: float
|
||||
run_timeout_seconds: float
|
||||
stream_text_delta_flush_interval_seconds: float
|
||||
stream_text_delta_max_chars: int
|
||||
layer_providers: tuple[DifyAgentLayerProvider, ...]
|
||||
plugin_daemon_http_client: FakePluginDaemonHttpClient
|
||||
dify_api_http_client: FakePluginDaemonHttpClient
|
||||
@@ -82,11 +84,15 @@ class FakeRunScheduler:
|
||||
dify_api_http_client: FakePluginDaemonHttpClient,
|
||||
shutdown_grace_seconds: float,
|
||||
run_timeout_seconds: float,
|
||||
stream_text_delta_flush_interval_seconds: float,
|
||||
stream_text_delta_max_chars: int,
|
||||
layer_providers: tuple[DifyAgentLayerProvider, ...],
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.shutdown_grace_seconds = shutdown_grace_seconds
|
||||
self.run_timeout_seconds = run_timeout_seconds
|
||||
self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds
|
||||
self.stream_text_delta_max_chars = stream_text_delta_max_chars
|
||||
self.layer_providers = layer_providers
|
||||
self.plugin_daemon_http_client = plugin_daemon_http_client
|
||||
self.dify_api_http_client = dify_api_http_client
|
||||
@@ -203,6 +209,9 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
shutdown_grace_seconds=5,
|
||||
run_timeout_seconds=17,
|
||||
run_retention_seconds=7,
|
||||
run_event_stream_max_length=23,
|
||||
stream_text_delta_flush_interval_ms=250,
|
||||
stream_text_delta_max_chars=2048,
|
||||
plugin_daemon_url="http://plugin-daemon",
|
||||
plugin_daemon_api_key="daemon-secret",
|
||||
inner_api_url="http://dify-api",
|
||||
@@ -226,6 +235,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
scheduler = FakeRunScheduler.created[0]
|
||||
assert scheduler.shutdown_grace_seconds == 5
|
||||
assert scheduler.run_timeout_seconds == 17
|
||||
assert scheduler.stream_text_delta_flush_interval_seconds == 0.25
|
||||
assert scheduler.stream_text_delta_max_chars == 2048
|
||||
layer_providers = scheduler.layer_providers
|
||||
assert isinstance(layer_providers, tuple)
|
||||
execution_context_provider = next(
|
||||
@@ -284,6 +295,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
store = scheduler.store
|
||||
assert isinstance(store, RedisRunStore)
|
||||
assert store.run_retention_seconds == 7
|
||||
assert store.run_event_stream_max_length == 23
|
||||
assert any(getattr(route, "path", None) == "/agent-stub/connections" for route in create_app(settings).routes)
|
||||
assert any(
|
||||
getattr(route, "path", None) == "/agent-stub/files/upload-request" for route in create_app(settings).routes
|
||||
|
||||
@@ -8,7 +8,11 @@ from pydantic import ValidationError
|
||||
|
||||
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
from dify_agent.server.settings import (
|
||||
DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH,
|
||||
DEFAULT_RUN_RETENTION_SECONDS,
|
||||
ServerSettings,
|
||||
)
|
||||
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
|
||||
from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS, E2BExecutionBindingBackend
|
||||
from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend, EnterpriseHomeSnapshotBackend
|
||||
@@ -79,6 +83,49 @@ def test_server_settings_rejects_non_positive_run_timeout() -> None:
|
||||
_ = ServerSettings(run_timeout_seconds=0)
|
||||
|
||||
|
||||
def test_server_settings_defaults_to_bounded_short_lived_run_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("DIFY_AGENT_RUN_RETENTION_SECONDS", raising=False)
|
||||
monkeypatch.delenv("DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH", raising=False)
|
||||
monkeypatch.delenv("DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS", raising=False)
|
||||
monkeypatch.delenv("DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.run_retention_seconds == DEFAULT_RUN_RETENTION_SECONDS == 7200
|
||||
assert settings.run_event_stream_max_length == DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH == 5000
|
||||
assert settings.stream_text_delta_flush_interval_ms == 100
|
||||
assert settings.stream_text_delta_max_chars == 4096
|
||||
|
||||
|
||||
def test_server_settings_reads_run_event_bounds_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH", "1234")
|
||||
monkeypatch.setenv("DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS", "250")
|
||||
monkeypatch.setenv("DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS", "2048")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.run_event_stream_max_length == 1234
|
||||
assert settings.stream_text_delta_flush_interval_ms == 250
|
||||
assert settings.stream_text_delta_max_chars == 2048
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("run_event_stream_max_length", 0),
|
||||
("stream_text_delta_flush_interval_ms", -1),
|
||||
("stream_text_delta_max_chars", 0),
|
||||
],
|
||||
)
|
||||
def test_server_settings_rejects_invalid_run_event_bounds(field: str, value: int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = ServerSettings(**{field: value}) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
def test_server_settings_reads_binding_file_download_command_timeout_from_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -24,7 +24,12 @@ from dify_agent.protocol.schemas import (
|
||||
)
|
||||
from dify_agent.runtime.cancellation import RunCancellationIntent
|
||||
from dify_agent.runtime.event_sink import RunFinalizationResult
|
||||
from dify_agent.storage.redis_run_store import DEFAULT_RUN_RETENTION_SECONDS, RedisRunStore, RunNotFoundError
|
||||
from dify_agent.storage.redis_run_store import (
|
||||
DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH,
|
||||
DEFAULT_RUN_RETENTION_SECONDS,
|
||||
RedisRunStore,
|
||||
RunNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
@@ -48,18 +53,34 @@ class FakeRedis:
|
||||
self.commands.append(("get", key))
|
||||
return self.values.get(key)
|
||||
|
||||
async def xadd(self, key: str, fields: Mapping[str, object]) -> str:
|
||||
self.commands.append(("xadd", key, dict(fields)))
|
||||
return self._append_stream_entry(key, fields)
|
||||
async def xadd(
|
||||
self,
|
||||
key: str,
|
||||
fields: Mapping[str, object],
|
||||
*,
|
||||
maxlen: int | None = None,
|
||||
approximate: bool = True,
|
||||
) -> str:
|
||||
self.commands.append(("xadd", key, dict(fields), maxlen, approximate))
|
||||
return self._append_stream_entry(key, fields, maxlen=maxlen)
|
||||
|
||||
def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> "FakeRedisPipeline":
|
||||
self.commands.append(("pipeline", transaction, shard_hint))
|
||||
return FakeRedisPipeline(self)
|
||||
|
||||
def _append_stream_entry(self, key: str, fields: Mapping[str, object]) -> str:
|
||||
def _append_stream_entry(
|
||||
self,
|
||||
key: str,
|
||||
fields: Mapping[str, object],
|
||||
*,
|
||||
maxlen: int | None = None,
|
||||
) -> str:
|
||||
entries = self.streams.setdefault(key, [])
|
||||
event_id = f"{len(entries) + 1}-0"
|
||||
next_sequence = self._stream_id_value(entries[-1][0])[0] + 1 if entries else 1
|
||||
event_id = f"{next_sequence}-0"
|
||||
entries.append((event_id, dict(fields)))
|
||||
if maxlen is not None and len(entries) > maxlen:
|
||||
del entries[: len(entries) - maxlen]
|
||||
self.stream_changed.set()
|
||||
return event_id
|
||||
|
||||
@@ -137,9 +158,16 @@ class FakeRedisPipeline:
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
del exc_type, exc, traceback
|
||||
|
||||
def xadd(self, key: str, fields: Mapping[str, object]) -> "FakeRedisPipeline":
|
||||
self.redis.commands.append(("xadd", key, dict(fields)))
|
||||
self.results.append(self.redis._append_stream_entry(key, fields))
|
||||
def xadd(
|
||||
self,
|
||||
key: str,
|
||||
fields: Mapping[str, object],
|
||||
*,
|
||||
maxlen: int | None = None,
|
||||
approximate: bool = True,
|
||||
) -> "FakeRedisPipeline":
|
||||
self.redis.commands.append(("xadd", key, dict(fields), maxlen, approximate))
|
||||
self.results.append(self.redis._append_stream_entry(key, fields, maxlen=maxlen))
|
||||
return self
|
||||
|
||||
def expire(self, key: str, seconds: int) -> "FakeRedisPipeline":
|
||||
@@ -184,6 +212,15 @@ def test_create_run_writes_running_record_without_job_queue_and_with_retention()
|
||||
assert "request" not in str(redis.commands[0][2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[{"run_retention_seconds": 0}, {"run_event_stream_max_length": 0}],
|
||||
)
|
||||
def test_rejects_non_positive_run_storage_bounds(kwargs: dict[str, int]) -> None:
|
||||
with pytest.raises(ValueError, match="must be positive"):
|
||||
_ = RedisRunStore(FakeRedis(), **kwargs) # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
def test_get_run_accepts_legacy_record_without_error_type() -> None:
|
||||
redis = FakeRedis()
|
||||
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
|
||||
@@ -223,6 +260,7 @@ def test_request_cancellation_maps_eval_result_and_arguments() -> None:
|
||||
assert intent_payload["reason"] == "workflow_aborted"
|
||||
assert intent_payload["message"] == "workflow stopped"
|
||||
assert eval_command[7] == "60"
|
||||
assert '"MAXLEN", "1"' in cast(str, eval_command[1])
|
||||
|
||||
|
||||
def test_finalize_cancellation_maps_eval_result_and_arguments() -> None:
|
||||
@@ -262,6 +300,8 @@ def test_finalize_cancellation_maps_eval_result_and_arguments() -> None:
|
||||
assert payload["data"]["usage"]["completion_tokens"] == 8
|
||||
assert payload["data"]["usage"]["total_tokens"] == 21
|
||||
assert eval_command[10] == "60"
|
||||
assert eval_command[11] == str(DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH)
|
||||
assert '"MAXLEN", "~", ARGV[6]' in cast(str, eval_command[1])
|
||||
|
||||
|
||||
def test_finalize_failed_run_maps_eval_result_and_arguments() -> None:
|
||||
@@ -290,6 +330,9 @@ def test_finalize_failed_run_maps_eval_result_and_arguments() -> None:
|
||||
assert eval_command[8:12] == ("1", "model failed", "1", "agent_run_limit_exceeded")
|
||||
payload = json.loads(cast(str, eval_command[12]))
|
||||
assert payload["data"]["error_type"] == "agent_run_limit_exceeded"
|
||||
assert eval_command[13] == str(DEFAULT_RUN_RETENTION_SECONDS)
|
||||
assert eval_command[14] == str(DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH)
|
||||
assert '"MAXLEN", "~", ARGV[9]' in cast(str, eval_command[1])
|
||||
|
||||
|
||||
def test_request_cancellation_raises_when_record_is_missing() -> None:
|
||||
@@ -368,6 +411,7 @@ def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() -
|
||||
assert isinstance(fields, dict)
|
||||
assert '"id"' not in str(fields["payload"])
|
||||
assert '"type":"run_started"' in str(fields["payload"])
|
||||
assert xadd_commands[0][3:] == (DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH, True)
|
||||
expire_commands = {command for command in redis.commands if command[0] == "expire"}
|
||||
assert expire_commands == {
|
||||
("expire", "test:runs:run-1:events", 60),
|
||||
@@ -376,6 +420,26 @@ def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() -
|
||||
assert ("execute",) in redis.commands
|
||||
|
||||
|
||||
def test_append_event_keeps_only_the_configured_number_of_entries() -> None:
|
||||
redis = FakeRedis()
|
||||
store = RedisRunStore(
|
||||
redis, # pyright: ignore[reportArgumentType]
|
||||
prefix="test",
|
||||
run_retention_seconds=60,
|
||||
run_event_stream_max_length=2,
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
for _ in range(3):
|
||||
_ = await store.append_event(RunStartedEvent(run_id="run-1"))
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert len(redis.streams["test:runs:run-1:events"]) == 2
|
||||
xadd_commands = [command for command in redis.commands if command[0] == "xadd"]
|
||||
assert all(command[3:] == (2, True) for command in xadd_commands)
|
||||
|
||||
|
||||
def test_get_events_round_trips_run_succeeded_output_and_session_snapshot() -> None:
|
||||
redis = FakeRedis()
|
||||
store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType]
|
||||
|
||||
@@ -715,7 +715,7 @@ services:
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
# https://docs.dify.ai/en/self-host/deploy/troubleshooting/docker-issues#why-is-ssrf-proxy-needed
|
||||
ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
|
||||
@@ -193,7 +193,7 @@ services:
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
# https://docs.dify.ai/en/self-host/deploy/troubleshooting/docker-issues#why-is-ssrf-proxy-needed
|
||||
ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
|
||||
@@ -721,7 +721,7 @@ services:
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
# https://docs.dify.ai/en/self-host/deploy/troubleshooting/docker-issues#why-is-ssrf-proxy-needed
|
||||
ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
|
||||
@@ -8,7 +8,10 @@ AGENT_BACKEND_BASE_URL=http://agent_backend:5050
|
||||
DIFY_AGENT_REDIS_URL=
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=7200
|
||||
DIFY_AGENT_RUN_EVENT_STREAM_MAX_LENGTH=5000
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_FLUSH_INTERVAL_MS=100
|
||||
DIFY_AGENT_STREAM_TEXT_DELTA_MAX_CHARS=4096
|
||||
# Internal deadline for the Agent Backend model/tool loop. The effective run
|
||||
# limit is whichever expires first: this value or the relevant API/Workflow
|
||||
# outer execution limit. To allow every path to run for one hour, also set
|
||||
|
||||
@@ -355,6 +355,7 @@ export type OpenApiErrorCode =
|
||||
| 'request_entity_too_large'
|
||||
| 'too_many_files'
|
||||
| 'too_many_requests'
|
||||
| 'trigger_workflow_service_mode_unavailable'
|
||||
| 'unauthorized'
|
||||
| 'unknown'
|
||||
| 'unsupported_file_type'
|
||||
|
||||
@@ -446,6 +446,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'request_entity_too_large',
|
||||
'too_many_files',
|
||||
'too_many_requests',
|
||||
'trigger_workflow_service_mode_unavailable',
|
||||
'unauthorized',
|
||||
'unknown',
|
||||
'unsupported_file_type',
|
||||
|
||||
114
pnpm-lock.yaml
generated
114
pnpm-lock.yaml
generated
@@ -457,8 +457,8 @@ catalogs:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
next:
|
||||
specifier: 16.3.2
|
||||
version: 16.3.2
|
||||
specifier: 16.3.3
|
||||
version: 16.3.3
|
||||
next-themes:
|
||||
specifier: 0.4.6
|
||||
version: 0.4.6
|
||||
@@ -1359,13 +1359,13 @@ importers:
|
||||
version: 1.1.0
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
version: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next-themes:
|
||||
specifier: 'catalog:'
|
||||
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
nuqs:
|
||||
specifier: 'catalog:'
|
||||
version: 2.10.0(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
version: 2.10.0(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
pinyin-pro:
|
||||
specifier: 'catalog:'
|
||||
version: 3.29.3
|
||||
@@ -1498,7 +1498,7 @@ importers:
|
||||
version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))
|
||||
'@storybook/nextjs-vite':
|
||||
specifier: 'catalog:'
|
||||
version: 10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
version: 10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
'@storybook/react':
|
||||
specifier: 'catalog:'
|
||||
version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
@@ -1600,7 +1600,7 @@ importers:
|
||||
version: 3.19.3
|
||||
vinext:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
version: 1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)'
|
||||
@@ -2842,57 +2842,57 @@ packages:
|
||||
'@next/env@16.0.0':
|
||||
resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==}
|
||||
|
||||
'@next/env@16.3.2':
|
||||
resolution: {integrity: sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==}
|
||||
'@next/env@16.3.3':
|
||||
resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.2':
|
||||
resolution: {integrity: sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==}
|
||||
'@next/swc-darwin-arm64@16.3.3':
|
||||
resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.3.2':
|
||||
resolution: {integrity: sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==}
|
||||
'@next/swc-darwin-x64@16.3.3':
|
||||
resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.2':
|
||||
resolution: {integrity: sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==}
|
||||
'@next/swc-linux-arm64-gnu@16.3.3':
|
||||
resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.2':
|
||||
resolution: {integrity: sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==}
|
||||
'@next/swc-linux-arm64-musl@16.3.3':
|
||||
resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.2':
|
||||
resolution: {integrity: sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==}
|
||||
'@next/swc-linux-x64-gnu@16.3.3':
|
||||
resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.2':
|
||||
resolution: {integrity: sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==}
|
||||
'@next/swc-linux-x64-musl@16.3.3':
|
||||
resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.2':
|
||||
resolution: {integrity: sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==}
|
||||
'@next/swc-win32-arm64-msvc@16.3.3':
|
||||
resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.2':
|
||||
resolution: {integrity: sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==}
|
||||
'@next/swc-win32-x64-msvc@16.3.3':
|
||||
resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -7327,8 +7327,8 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.3.2:
|
||||
resolution: {integrity: sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==}
|
||||
next@16.3.3:
|
||||
resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -10464,30 +10464,30 @@ snapshots:
|
||||
|
||||
'@next/env@16.0.0': {}
|
||||
|
||||
'@next/env@16.3.2': {}
|
||||
'@next/env@16.3.3': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.2':
|
||||
'@next/swc-darwin-arm64@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.3.2':
|
||||
'@next/swc-darwin-x64@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.2':
|
||||
'@next/swc-linux-arm64-gnu@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.2':
|
||||
'@next/swc-linux-arm64-musl@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.2':
|
||||
'@next/swc-linux-x64-gnu@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.2':
|
||||
'@next/swc-linux-x64-musl@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.2':
|
||||
'@next/swc-win32-arm64-msvc@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.2':
|
||||
'@next/swc-win32-x64-msvc@16.3.3':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -11231,18 +11231,18 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
'@storybook/nextjs-vite@10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)':
|
||||
'@storybook/nextjs-vite@10.5.10(@babel/core@7.29.7(supports-color@11.0.0))(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.5.10(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))
|
||||
'@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
'@storybook/react-vite': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
next: 16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@11.0.0))(react@19.2.8)
|
||||
vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)'
|
||||
vite-plugin-storybook-nextjs: 3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
vite-plugin-storybook-nextjs: 3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
'@types/react-dom': 19.2.5(@types/react@19.2.18)
|
||||
@@ -11932,13 +11932,13 @@ snapshots:
|
||||
dependencies:
|
||||
unpic: 4.2.2
|
||||
|
||||
'@unpic/react@1.0.2(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@unpic/react@1.0.2(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@unpic/core': 1.0.3
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
optionalDependencies:
|
||||
next: 16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
optionalDependencies:
|
||||
@@ -14915,9 +14915,9 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@next/env': 16.3.2
|
||||
'@next/env': 16.3.3
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.10.40
|
||||
caniuse-lite: 1.0.30001799
|
||||
@@ -14926,14 +14926,14 @@ snapshots:
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@11.0.0))(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.2
|
||||
'@next/swc-darwin-x64': 16.3.2
|
||||
'@next/swc-linux-arm64-gnu': 16.3.2
|
||||
'@next/swc-linux-arm64-musl': 16.3.2
|
||||
'@next/swc-linux-x64-gnu': 16.3.2
|
||||
'@next/swc-linux-x64-musl': 16.3.2
|
||||
'@next/swc-win32-arm64-msvc': 16.3.2
|
||||
'@next/swc-win32-x64-msvc': 16.3.2
|
||||
'@next/swc-darwin-arm64': 16.3.3
|
||||
'@next/swc-darwin-x64': 16.3.3
|
||||
'@next/swc-linux-arm64-gnu': 16.3.3
|
||||
'@next/swc-linux-arm64-musl': 16.3.3
|
||||
'@next/swc-linux-x64-gnu': 16.3.3
|
||||
'@next/swc-linux-x64-musl': 16.3.3
|
||||
'@next/swc-win32-arm64-msvc': 16.3.3
|
||||
'@next/swc-win32-x64-msvc': 16.3.3
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.3(@types/node@25.9.5)
|
||||
transitivePeerDependencies:
|
||||
@@ -14967,12 +14967,12 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuqs@2.10.0(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
|
||||
nuqs@2.10.0(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
next: 16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
@@ -16456,9 +16456,9 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vinext@1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
|
||||
vinext@1.0.0-beta.8(@vitejs/plugin-react@6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.2.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@unpic/react': 1.0.2(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@unpic/react': 1.0.2(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vercel/og': 0.8.6
|
||||
'@vinext/types': 1.0.0-beta.2
|
||||
'@vitejs/plugin-react': 6.1.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))
|
||||
@@ -16505,13 +16505,13 @@ snapshots:
|
||||
- srvx
|
||||
- typescript
|
||||
|
||||
vite-plugin-storybook-nextjs@3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0):
|
||||
vite-plugin-storybook-nextjs@3.3.0(@typescript/typescript6@6.0.2)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(supports-color@11.0.0):
|
||||
dependencies:
|
||||
'@next/env': 16.0.0
|
||||
image-size: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
module-alias: 2.3.4
|
||||
next: 16.3.2(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
next: 16.3.3(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8)(vite-plus@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(esbuild@0.28.2)(happy-dom@20.11.6)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))
|
||||
ts-dedent: 2.3.0
|
||||
vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)'
|
||||
@@ -17052,7 +17052,7 @@ time:
|
||||
motion@12.43.0: '2026-07-28T14:29:31.291Z'
|
||||
negotiator@1.1.0: '2026-08-20T16:45:05.235Z'
|
||||
next-themes@0.4.6: '2025-03-11T21:02:05.882Z'
|
||||
next@16.3.2: '2026-08-21T09:54:02.099Z'
|
||||
next@16.3.3: '2026-08-25T15:32:19.558Z'
|
||||
nuqs@2.10.0: '2026-08-20T08:10:12.660Z'
|
||||
open@11.0.1: '2026-08-13T23:39:43.829Z'
|
||||
ora@9.4.1: '2026-06-22T12:24:49.225Z'
|
||||
|
||||
@@ -213,7 +213,7 @@ catalog:
|
||||
mitt: 3.0.1
|
||||
motion: 12.43.0
|
||||
negotiator: 1.1.0
|
||||
next: 16.3.2
|
||||
next: 16.3.3
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.10.0
|
||||
open: 11.0.1
|
||||
|
||||
106
web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx
Normal file
106
web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state'
|
||||
import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { AmplitudeIdentitySync } from '../external-service-sync'
|
||||
|
||||
const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSetUserId: vi.fn(),
|
||||
mockSetUserProperties: vi.fn(),
|
||||
mockTrackEvent: vi.fn((..._args: unknown[]) => ({
|
||||
promise: Promise.resolve({ code: 200 }),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...original,
|
||||
useSuspenseQuery: () => ({
|
||||
data: {
|
||||
id: 'account-id',
|
||||
email: 'person@example.com',
|
||||
name: 'Person',
|
||||
is_password_set: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...original,
|
||||
useAtomValue: () => ({
|
||||
id: 'workspace-id',
|
||||
name: 'Workspace',
|
||||
plan: 'professional',
|
||||
role: 'owner',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
userProfileQueryOptions: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
setUserId: (...args: unknown[]) => mockSetUserId(...args),
|
||||
setUserProperties: (...args: unknown[]) => mockSetUserProperties(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/init', () => ({
|
||||
getIsAmplitudeInitialized: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/consent-store', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@/app/components/base/analytics-consent/consent-store')>()
|
||||
return {
|
||||
...original,
|
||||
getAnalyticsConsent: () => 'granted',
|
||||
}
|
||||
})
|
||||
|
||||
describe('AmplitudeIdentitySync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.clear()
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
)
|
||||
})
|
||||
|
||||
it('sets identity before flushing a marker that already exists', async () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
|
||||
render(<AmplitudeIdentitySync />)
|
||||
|
||||
await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUserId).toHaveBeenCalledWith('person@example.com')
|
||||
expect(mockSetUserProperties).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetUserId.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockTrackEvent.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
expect(mockSetUserProperties.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockTrackEvent.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes a marker created after identity sync without repeating unchanged identity updates', async () => {
|
||||
render(<AmplitudeIdentitySync />)
|
||||
|
||||
await waitFor(() => expect(mockSetUserId).toHaveBeenCalledTimes(1))
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUserId).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetUserProperties).toHaveBeenCalledTimes(1)
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,13 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu
|
||||
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import {
|
||||
flushRegistrationSuccess,
|
||||
getRegistrationSuccessSnapshot,
|
||||
subscribeRegistrationSuccess,
|
||||
} from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { useAmplitudeInitialized } from '@/app/components/base/amplitude/use-amplitude-initialized'
|
||||
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
@@ -43,13 +47,18 @@ function buildAmplitudeProperties({
|
||||
return properties
|
||||
}
|
||||
|
||||
function AmplitudeIdentitySync() {
|
||||
export function AmplitudeIdentitySync() {
|
||||
const { data: userProfile } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile,
|
||||
})
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const lastIdentityRef = useRef<string | undefined>(undefined)
|
||||
const registrationSnapshot = useSyncExternalStore(
|
||||
subscribeRegistrationSuccess,
|
||||
getRegistrationSuccessSnapshot,
|
||||
getRegistrationSuccessSnapshot,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile.id) return
|
||||
@@ -63,13 +72,14 @@ function AmplitudeIdentitySync() {
|
||||
properties,
|
||||
})
|
||||
|
||||
if (identity === lastIdentityRef.current) return
|
||||
if (identity !== lastIdentityRef.current) {
|
||||
setUserId(userProfile.email)
|
||||
setUserProperties(properties)
|
||||
lastIdentityRef.current = identity
|
||||
}
|
||||
|
||||
setUserId(userProfile.email)
|
||||
setUserProperties(properties)
|
||||
flushRegistrationSuccess()
|
||||
lastIdentityRef.current = identity
|
||||
}, [currentWorkspace, userProfile])
|
||||
void flushRegistrationSuccess()
|
||||
}, [currentWorkspace, registrationSnapshot, userProfile])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { StrictMode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
const {
|
||||
mockConsent,
|
||||
mockNormalizeRegistrationAttribution,
|
||||
mockRememberRegistrationSuccess,
|
||||
mockSendGAEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockConsent: { value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled' },
|
||||
mockNormalizeRegistrationAttribution: vi.fn((value: Record<string, unknown> | null) => {
|
||||
if (!value) return null
|
||||
const allowed = Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([key, item]) =>
|
||||
['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'].includes(
|
||||
key,
|
||||
) && typeof item === 'string',
|
||||
),
|
||||
)
|
||||
return Object.keys(allowed).length ? allowed : null
|
||||
}),
|
||||
mockRememberRegistrationSuccess: vi.fn(),
|
||||
mockSendGAEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
@@ -17,7 +36,14 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/analytics-consent/consent-store', () => ({
|
||||
useAnalyticsConsent: () => mockConsent.value,
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude/registration-tracking', () => ({
|
||||
normalizeRegistrationAttribution: (
|
||||
...args: Parameters<typeof mockNormalizeRegistrationAttribution>
|
||||
) => mockNormalizeRegistrationAttribution(...args),
|
||||
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||
}))
|
||||
|
||||
@@ -33,22 +59,74 @@ const setSearchParams = (searchParams = '') => {
|
||||
describe('OAuthRegistrationAnalytics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.clear()
|
||||
mockConsent.value = 'granted'
|
||||
mockRememberRegistrationSuccess.mockReturnValue(true)
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams()
|
||||
})
|
||||
|
||||
it('should track oauth registration with utm info and clear the query flag', async () => {
|
||||
it('queues the Amplitude marker while consent is unknown and cleans the URL after persist', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch' }))
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
expect(window.location.search).toBe('?source=signin')
|
||||
})
|
||||
|
||||
it('keeps the recoverable OAuth signal when marker persistence fails', () => {
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin' }))
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
mockRememberRegistrationSuccess.mockReturnValue(false)
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.search).toBe('?oauth_new_user=true&source=signin')
|
||||
expect(Cookies.get('utm_info')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the OAuth marker while consent is unknown, then cleans without a second Amplitude queue on denial', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const { rerender } = render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockConsent.value = 'denied'
|
||||
rerender(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('queues immediately with pre-granted consent and keeps only allowlisted UTM fields', async () => {
|
||||
Cookies.set(
|
||||
'utm_info',
|
||||
JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
arbitrary: 'discard-me',
|
||||
utm_term: { nested: true },
|
||||
}),
|
||||
)
|
||||
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
@@ -64,16 +142,13 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
|
||||
})
|
||||
expect(window.location.search).toBe('?source=signin')
|
||||
})
|
||||
|
||||
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
|
||||
it('uses the base event and cleans up when the UTM cookie is malformed', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -89,23 +164,77 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
it('cleans a false OAuth marker immediately without tracking or clearing utm_info', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=false')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBe(JSON.stringify({ utm_source: 'blog' }))
|
||||
})
|
||||
|
||||
it('tracks GA and Amplitude once across StrictMode effects and rerenders', async () => {
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const { rerender } = render(
|
||||
<StrictMode>
|
||||
<OAuthRegistrationAnalytics />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<StrictMode>
|
||||
<OAuthRegistrationAnalytics />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tracks GA once across an unknown-consent remount that simulates reload', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
const firstRender = render(<OAuthRegistrationAnalytics />)
|
||||
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
|
||||
firstRender.unmount()
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats analytics-disabled consent as terminal and cleans without Amplitude', async () => {
|
||||
mockConsent.value = 'disabled'
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
|
||||
setSearchParams('oauth_new_user=true')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => expect(window.location.search).toBe(''))
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing without the OAuth registration query marker', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear a false oauth registration query flag without tracking', async () => {
|
||||
setSearchParams('oauth_new_user=false')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
it('clears an abandoned flow guard so a later OAuth registration can emit GA', () => {
|
||||
window.sessionStorage.setItem('oauth_registration_ga_sent', 'true')
|
||||
const abandonedFlow = render(<OAuthRegistrationAnalytics />)
|
||||
abandonedFlow.unmount()
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,8 @@ export function AmplitudeProvider({
|
||||
sessionReplaySampleRate,
|
||||
})
|
||||
setAmplitudeOptOut(false)
|
||||
|
||||
return () => setAmplitudeOptOut(true)
|
||||
}, [active, consent, sessionReplaySampleRate])
|
||||
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { sendWebAppAmplitudeEvent, setWebAppAmplitudeOptOut } from './web-app-client'
|
||||
import { registerWebAppEventTracker } from './web-app-event'
|
||||
|
||||
export function WebAppAmplitudeProvider() {
|
||||
const consent = useAnalyticsConsent()
|
||||
|
||||
useEffect(() => {
|
||||
if (consent !== 'granted') {
|
||||
setWebAppAmplitudeOptOut(true)
|
||||
return
|
||||
}
|
||||
|
||||
setWebAppAmplitudeOptOut(false)
|
||||
const unregisterTracker = registerWebAppEventTracker(sendWebAppAmplitudeEvent)
|
||||
|
||||
return () => {
|
||||
unregisterTracker()
|
||||
setWebAppAmplitudeOptOut(true)
|
||||
}
|
||||
}, [consent])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -100,10 +100,10 @@ describe('AmplitudeProvider', () => {
|
||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opts out when the runtime leaves the first-party route boundary', () => {
|
||||
const { rerender } = render(<AmplitudeProvider active />)
|
||||
it('opts out when the analytics layout runtime unmounts', () => {
|
||||
const { unmount } = render(<AmplitudeProvider />)
|
||||
|
||||
rerender(<AmplitudeProvider active={false} />)
|
||||
unmount()
|
||||
|
||||
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
@@ -145,6 +145,9 @@ describe('AmplitudeProvider', () => {
|
||||
window.location.pathname = '/apps'
|
||||
await execute(event)
|
||||
expect(getPageTitle(event)).toBe('Studio')
|
||||
window.location.pathname = '/agents'
|
||||
await execute(event)
|
||||
expect(getPageTitle(event)).toBe('Agents')
|
||||
window.location.pathname = '/explore'
|
||||
await execute(event)
|
||||
expect(getPageTitle(event)).toBe('Explore')
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { trackWebAppEvent } from '../web-app-event'
|
||||
import { WebAppAmplitudeProvider } from '../WebAppAmplitudeProvider'
|
||||
|
||||
const { mockClient, mockConsent } = vi.hoisted(() => ({
|
||||
mockClient: {
|
||||
ensureInitialized: vi.fn(),
|
||||
sendEvent: vi.fn(),
|
||||
setOptOut: vi.fn(),
|
||||
},
|
||||
mockConsent: {
|
||||
value: 'unknown' as 'unknown' | 'denied' | 'granted',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
|
||||
useAnalyticsConsent: () => mockConsent.value,
|
||||
}))
|
||||
|
||||
vi.mock('../web-app-client', () => ({
|
||||
ensureWebAppAmplitudeInitialized: () => mockClient.ensureInitialized(),
|
||||
sendWebAppAmplitudeEvent: (...args: unknown[]) => mockClient.sendEvent(...args),
|
||||
setWebAppAmplitudeOptOut: (optOut: boolean) => mockClient.setOptOut(optOut),
|
||||
}))
|
||||
|
||||
describe('WebAppAmplitudeProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockClient.ensureInitialized.mockReset()
|
||||
mockClient.ensureInitialized.mockReturnValue(Promise.resolve())
|
||||
mockConsent.value = 'unknown'
|
||||
})
|
||||
|
||||
it('registers explicit tracking only while analytics consent is granted', () => {
|
||||
const { rerender } = render(<WebAppAmplitudeProvider />)
|
||||
|
||||
trackWebAppEvent('webapp_run', { app_mode: 'agent-v2' })
|
||||
expect(mockClient.ensureInitialized).not.toHaveBeenCalled()
|
||||
expect(mockClient.sendEvent).not.toHaveBeenCalled()
|
||||
|
||||
mockClient.ensureInitialized.mockReturnValue(new Promise(() => {}))
|
||||
mockConsent.value = 'granted'
|
||||
rerender(<WebAppAmplitudeProvider />)
|
||||
|
||||
expect(mockClient.ensureInitialized).not.toHaveBeenCalled()
|
||||
|
||||
trackWebAppEvent('webapp_run', { app_mode: 'agent-v2' })
|
||||
|
||||
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(false)
|
||||
expect(mockClient.sendEvent).toHaveBeenCalledWith('webapp_run', {
|
||||
app_mode: 'agent-v2',
|
||||
})
|
||||
|
||||
mockConsent.value = 'denied'
|
||||
rerender(<WebAppAmplitudeProvider />)
|
||||
mockClient.sendEvent.mockClear()
|
||||
trackWebAppEvent('webapp_run', { app_mode: 'chatflow' })
|
||||
|
||||
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(true)
|
||||
expect(mockClient.sendEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unregisters tracking and opts out when the WebApp layout unmounts', () => {
|
||||
mockConsent.value = 'granted'
|
||||
const { unmount } = render(<WebAppAmplitudeProvider />)
|
||||
|
||||
unmount()
|
||||
mockClient.sendEvent.mockClear()
|
||||
trackWebAppEvent('webapp_run', { app_mode: 'workflow' })
|
||||
|
||||
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(true)
|
||||
expect(mockClient.sendEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +1,182 @@
|
||||
import {
|
||||
flushRegistrationSuccess,
|
||||
discardRegistrationSessionState,
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
} from '../registration-session-state'
|
||||
import {
|
||||
coordinateRegistrationConsent,
|
||||
flushRegistrationSuccess,
|
||||
rememberRegistrationSuccess,
|
||||
subscribeRegistrationSuccess,
|
||||
} from '../registration-tracking'
|
||||
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
const mockAmplitudeInitialized = vi.hoisted(() => ({ value: true }))
|
||||
const mockConsent = vi.hoisted(() => ({
|
||||
value: 'granted' as 'unknown' | 'denied' | 'granted',
|
||||
value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled',
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../init', () => ({
|
||||
getIsAmplitudeInitialized: () => mockAmplitudeInitialized.value,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
|
||||
getAnalyticsConsent: () => mockConsent.value,
|
||||
}))
|
||||
|
||||
const successResult = () => ({
|
||||
promise: Promise.resolve({ code: 200 }),
|
||||
})
|
||||
|
||||
const getStoredMarker = () =>
|
||||
JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)
|
||||
|
||||
describe('registration tracking', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
window.sessionStorage.clear()
|
||||
mockConsent.value = 'granted'
|
||||
mockAmplitudeInitialized.value = true
|
||||
mockTrackEvent.mockImplementation(successResult)
|
||||
coordinateRegistrationConsent('denied')
|
||||
mockConsent.value = 'granted'
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
)
|
||||
})
|
||||
|
||||
// Captures the registration event for a later flush instead of firing it right away.
|
||||
describe('rememberRegistrationSuccess', () => {
|
||||
it('should store the base event and not track immediately when there is no utm info', () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
afterEach(() => {
|
||||
discardRegistrationSessionState()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success',
|
||||
properties: { method: 'email' },
|
||||
describe('rememberRegistrationSuccess', () => {
|
||||
it('stores a versioned marker with stable delivery metadata and allowlisted attribution', () => {
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
|
||||
const persisted = rememberRegistrationSuccess({
|
||||
method: 'email',
|
||||
utmInfo: {
|
||||
utm_source: 'linkedin',
|
||||
utm_medium: 'social',
|
||||
utm_campaign: 'launch',
|
||||
utm_content: 'hero',
|
||||
utm_term: 'agents',
|
||||
slug: 'agent-launch',
|
||||
unexpected: 'discard-me',
|
||||
nested: { unsafe: true },
|
||||
},
|
||||
})
|
||||
|
||||
const occurredAt = Date.now()
|
||||
expect(getStoredMarker()).toEqual({
|
||||
version: 2,
|
||||
registrationId: '11111111-1111-4111-8111-111111111111',
|
||||
occurredAt,
|
||||
expiresAt: occurredAt + 24 * 60 * 60 * 1000,
|
||||
eventName: 'user_registration_success_with_utm',
|
||||
method: 'email',
|
||||
attribution: {
|
||||
utm_source: 'linkedin',
|
||||
utm_medium: 'social',
|
||||
utm_campaign: 'launch',
|
||||
utm_content: 'hero',
|
||||
utm_term: 'agents',
|
||||
slug: 'agent-launch',
|
||||
},
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(persisted).toBe(true)
|
||||
})
|
||||
|
||||
it('should store the utm event and merge utm info into properties when utm info is present', () => {
|
||||
rememberRegistrationSuccess({
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
it('persists the latest email marker while consent is unknown so a later grant can flush it', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'first' } })
|
||||
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'latest' } })
|
||||
|
||||
expect(getStoredMarker()).toMatchObject({
|
||||
version: 2,
|
||||
method: 'email',
|
||||
attribution: { utm_source: 'latest' },
|
||||
})
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success_with_utm',
|
||||
properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
await flushRegistrationSuccess()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
|
||||
mockConsent.value = 'granted'
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should swallow errors when writing to sessionStorage fails', () => {
|
||||
it('discards an unknown-consent marker on denial before a later grant can flush it', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
coordinateRegistrationConsent('denied')
|
||||
mockConsent.value = 'granted'
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('discards a pending marker and GA guard at an account boundary', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
window.sessionStorage.setItem('oauth_registration_ga_sent', 'true')
|
||||
|
||||
discardRegistrationSessionState()
|
||||
mockConsent.value = 'granted'
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
expect(window.sessionStorage.getItem('oauth_registration_ga_sent')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['denied', 'disabled'] as const)(
|
||||
'discards a stored marker when consent changes to %s',
|
||||
(consent) => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
coordinateRegistrationConsent(consent)
|
||||
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('persists an oauth marker while consent is unknown so a reload can still flush it', async () => {
|
||||
mockConsent.value = 'unknown'
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
|
||||
expect(getStoredMarker()).toMatchObject({ method: 'oauth' })
|
||||
|
||||
mockConsent.value = 'granted'
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies consumers only after a marker is stored', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeRegistrationSuccess(listener)
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('swallows sessionStorage write errors without notifying consumers', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeRegistrationSuccess(listener)
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
@@ -60,158 +187,365 @@ describe('registration tracking', () => {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(rememberRegistrationSuccess({ method: 'email' })).toBe(false)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushRegistrationSuccess', () => {
|
||||
it('waits for a successful SDK result before acknowledging the marker', async () => {
|
||||
let resolveTrack!: (result: { code: number }) => void
|
||||
mockTrackEvent.mockReturnValue({
|
||||
promise: new Promise((resolve) => {
|
||||
resolveTrack = resolve
|
||||
}),
|
||||
})
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
rememberRegistrationSuccess({ method: 'oauth', utmInfo: { utm_source: 'blog' } })
|
||||
|
||||
const flushPromise = flushRegistrationSuccess()
|
||||
|
||||
expect(getStoredMarker()).toBeTruthy()
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith(
|
||||
'user_registration_success_with_utm',
|
||||
{
|
||||
method: 'oauth',
|
||||
utm_source: 'blog',
|
||||
registration_id: '11111111-1111-4111-8111-111111111111',
|
||||
event_version: 2,
|
||||
tracking_contract_version: 'consent_wait_v2',
|
||||
},
|
||||
{
|
||||
insert_id: '11111111-1111-4111-8111-111111111111',
|
||||
time: Date.now(),
|
||||
},
|
||||
)
|
||||
|
||||
resolveTrack({ code: 200 })
|
||||
await flushPromise
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['unknown', 'denied'] as const)(
|
||||
'should not cache an event while consent is %s',
|
||||
(consent) => {
|
||||
mockConsent.value = consent
|
||||
it.each([
|
||||
['unknown consent', () => (mockConsent.value = 'unknown')],
|
||||
['uninitialized Amplitude', () => (mockAmplitudeInitialized.value = false)],
|
||||
])('defers without deleting for %s', async (_label, makeIneligible) => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
makeIneligible()
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(getStoredMarker()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('discards a pending marker when consent is denied', async () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
mockConsent.value = 'denied'
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('retains the marker on SDK rejection or a non-success result and reuses its id and time', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const marker = getStoredMarker()
|
||||
mockTrackEvent
|
||||
.mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) })
|
||||
.mockReturnValueOnce({ promise: Promise.resolve({ code: 500 }) })
|
||||
.mockImplementation(successResult)
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(getStoredMarker()).toEqual(marker)
|
||||
expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual({
|
||||
insert_id: marker.registrationId,
|
||||
time: marker.occurredAt,
|
||||
})
|
||||
expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({
|
||||
insert_id: marker.registrationId,
|
||||
time: marker.occurredAt,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['rejected acknowledgement', 'non-success acknowledgement'] as const)(
|
||||
'continues with a replacement marker after a %s',
|
||||
async (oldAcknowledgement) => {
|
||||
const firstRegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const replacementRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID')
|
||||
.mockReturnValueOnce(firstRegistrationId)
|
||||
.mockReturnValueOnce(replacementRegistrationId)
|
||||
let resolveOldAcknowledgement!: (result: { code: number }) => void
|
||||
let rejectOldAcknowledgement!: (error: Error) => void
|
||||
const pendingOldAcknowledgement = new Promise<{ code: number }>((resolve, reject) => {
|
||||
resolveOldAcknowledgement = resolve
|
||||
rejectOldAcknowledgement = reject
|
||||
})
|
||||
mockTrackEvent
|
||||
.mockReturnValueOnce({ promise: pendingOldAcknowledgement })
|
||||
.mockImplementation(successResult)
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const firstFlush = flushRegistrationSuccess()
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const replacementFlush = flushRegistrationSuccess()
|
||||
|
||||
expect(replacementFlush).toBe(firstFlush)
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
|
||||
if (oldAcknowledgement === 'rejected acknowledgement')
|
||||
rejectOldAcknowledgement(new Error('network failed'))
|
||||
else resolveOldAcknowledgement({ code: 500 })
|
||||
await firstFlush
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({
|
||||
insert_id: replacementRegistrationId,
|
||||
time: expect.any(Number),
|
||||
})
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Replays the remembered event exactly once, after the user ID has been attached.
|
||||
describe('flushRegistrationSuccess', () => {
|
||||
it('should track the remembered event and clear it from storage', () => {
|
||||
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } })
|
||||
|
||||
flushRegistrationSuccess()
|
||||
it('retries an acknowledgement-failed marker after the backoff delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const marker = getStoredMarker()
|
||||
mockTrackEvent
|
||||
.mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) })
|
||||
.mockImplementation(successResult)
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
expect(getStoredMarker()).toEqual(marker)
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'email',
|
||||
utm_source: 'blog',
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({
|
||||
insert_id: marker.registrationId,
|
||||
time: marker.occurredAt,
|
||||
})
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should do nothing when there is no pending event', () => {
|
||||
flushRegistrationSuccess()
|
||||
it('does not retry an acknowledgement-failed marker after an account boundary', async () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
mockTrackEvent.mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) })
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull()
|
||||
|
||||
discardRegistrationSessionState()
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['session discard', 'resolves'] as const,
|
||||
['session discard', 'rejects'] as const,
|
||||
['denied consent', 'resolves'] as const,
|
||||
['disabled analytics', 'resolves'] as const,
|
||||
])(
|
||||
'isolates a new registration flush after %s while the old SDK acknowledgement %s',
|
||||
async (invalidation, oldAcknowledgement) => {
|
||||
const accountARegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const accountBRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID')
|
||||
.mockReturnValueOnce(accountARegistrationId)
|
||||
.mockReturnValueOnce(accountBRegistrationId)
|
||||
|
||||
let resolveAccountA!: (result: { code: number }) => void
|
||||
let rejectAccountA!: (error: Error) => void
|
||||
let resolveAccountB!: (result: { code: number }) => void
|
||||
const accountAAcknowledgement = new Promise<{ code: number }>((resolve, reject) => {
|
||||
resolveAccountA = resolve
|
||||
rejectAccountA = reject
|
||||
})
|
||||
const accountBAcknowledgement = new Promise<{ code: number }>((resolve) => {
|
||||
resolveAccountB = resolve
|
||||
})
|
||||
mockTrackEvent
|
||||
.mockReturnValueOnce({ promise: accountAAcknowledgement })
|
||||
.mockReturnValueOnce({ promise: accountBAcknowledgement })
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const accountAFlush = flushRegistrationSuccess()
|
||||
|
||||
if (invalidation === 'session discard') {
|
||||
discardRegistrationSessionState()
|
||||
} else {
|
||||
const terminalConsent = invalidation === 'denied consent' ? 'denied' : 'disabled'
|
||||
mockConsent.value = terminalConsent
|
||||
coordinateRegistrationConsent(terminalConsent)
|
||||
mockConsent.value = 'granted'
|
||||
}
|
||||
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const accountBMarker = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
const accountBFlush = flushRegistrationSuccess()
|
||||
|
||||
const settleAccountA = () => {
|
||||
if (oldAcknowledgement === 'resolves') resolveAccountA({ code: 200 })
|
||||
else rejectAccountA(new Error('account A request failed'))
|
||||
}
|
||||
|
||||
try {
|
||||
expect(accountBMarker).not.toBeNull()
|
||||
expect(accountBFlush).not.toBe(accountAFlush)
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent.mock.calls.map((call) => call[2])).toEqual([
|
||||
{ insert_id: accountARegistrationId, time: expect.any(Number) },
|
||||
{ insert_id: accountBRegistrationId, time: expect.any(Number) },
|
||||
])
|
||||
|
||||
settleAccountA()
|
||||
await accountAFlush
|
||||
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe(
|
||||
accountBMarker,
|
||||
)
|
||||
expect(flushRegistrationSuccess()).toBe(accountBFlush)
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolveAccountB({ code: 200 })
|
||||
await accountBFlush
|
||||
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
} finally {
|
||||
settleAccountA()
|
||||
resolveAccountB({ code: 200 })
|
||||
await Promise.allSettled([accountAFlush, accountBFlush])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('coalesces concurrent flushes into one SDK send', async () => {
|
||||
let resolveTrack!: (result: { code: number }) => void
|
||||
mockTrackEvent.mockReturnValue({
|
||||
promise: new Promise((resolve) => {
|
||||
resolveTrack = resolve
|
||||
}),
|
||||
})
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
const first = flushRegistrationSuccess()
|
||||
const second = flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
resolveTrack({ code: 200 })
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
it('discards expired and malformed markers without tracking', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
vi.setSystemTime(new Date('2026-08-27T09:00:00.000Z'))
|
||||
|
||||
await flushRegistrationSuccess()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
|
||||
const malformedMarkers = [
|
||||
'{not-json',
|
||||
JSON.stringify({ version: 1, eventName: 'user_registration_success' }),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
registrationId: 'id',
|
||||
occurredAt: Date.now(),
|
||||
expiresAt: Date.now() + 1000,
|
||||
eventName: 'arbitrary_event',
|
||||
method: 'email',
|
||||
attribution: {},
|
||||
}),
|
||||
]
|
||||
|
||||
for (const raw of malformedMarkers) {
|
||||
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, raw)
|
||||
await flushRegistrationSuccess()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
}
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire the event at most once across repeated flushes', () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
it('accepts a persisted timestamp just inside the five-minute clock-skew allowance', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-26T09:04:59.999Z'))
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
|
||||
flushRegistrationSuccess()
|
||||
flushRegistrationSuccess()
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should discard a pending event when consent was revoked before flush', () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
mockConsent.value = 'denied'
|
||||
it('rejects a persisted timestamp just outside the five-minute clock-skew allowance', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-26T09:05:00.001Z'))
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z'))
|
||||
|
||||
flushRegistrationSuccess()
|
||||
await flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear malformed pending data without tracking', () => {
|
||||
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear the pending entry without tracking when it has no event name', () => {
|
||||
window.sessionStorage.setItem(
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
JSON.stringify({ properties: { method: 'email' } }),
|
||||
)
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should stop without tracking when reading from sessionStorage throws', () => {
|
||||
it('handles storage read errors without throwing', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => {
|
||||
throw new Error('read failed')
|
||||
},
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('should still track when clearing the pending entry fails', () => {
|
||||
const pending = { eventName: 'user_registration_success', properties: { method: 'email' } }
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => JSON.stringify(pending),
|
||||
setItem: vi.fn(),
|
||||
removeItem: () => {
|
||||
throw new Error('remove failed')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'email',
|
||||
})
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Both producers and the consumer must degrade gracefully when sessionStorage is
|
||||
// missing (SSR) or blocked (privacy mode / disabled storage).
|
||||
describe('when sessionStorage is unavailable', () => {
|
||||
it('should no-op without throwing when window is undefined', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
await expect(flushRegistrationSuccess()).resolves.toBeUndefined()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should no-op without throwing when accessing sessionStorage throws', () => {
|
||||
it('retains the same marker when acknowledgement removal fails', async () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
const raw = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
const removeItem = vi.fn(() => {
|
||||
throw new Error('remove failed')
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => raw,
|
||||
setItem: vi.fn(),
|
||||
removeItem,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(flushRegistrationSuccess()).resolves.toBeUndefined()
|
||||
await expect(flushRegistrationSuccess()).resolves.toBeUndefined()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual(mockTrackEvent.mock.calls[1]?.[2])
|
||||
expect(removeItem).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('no-ops when sessionStorage access is blocked', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
get sessionStorage() {
|
||||
throw new Error('storage disabled')
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
await expect(flushRegistrationSuccess()).resolves.toBeUndefined()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Types } from '@amplitude/analytics-browser'
|
||||
|
||||
const TEST_API_KEY = 'test-api-key'
|
||||
const CONSOLE_IDENTITY_COOKIE_NAME = `AMP_${TEST_API_KEY.slice(0, 10)}`
|
||||
const CONSOLE_UNSENT_EVENTS_KEY = `AMP_unsent_${TEST_API_KEY.slice(0, 10)}`
|
||||
|
||||
const { mockConfig, mockCreateInstance, mockIdentity, mockTrackedEvents, mockWebAppClient } =
|
||||
vi.hoisted(() => {
|
||||
const identity = {
|
||||
userId: undefined as string | undefined,
|
||||
}
|
||||
const trackedEvents: Array<{
|
||||
eventName: string
|
||||
properties: Record<string, unknown>
|
||||
userId: string | undefined
|
||||
}> = []
|
||||
const mockWebAppClient = {
|
||||
init: vi.fn((apiKey: string, options?: Types.BrowserOptions) => {
|
||||
const identityCookieName = `AMP_${apiKey.slice(0, 10)}=`
|
||||
const identityCookie = document.cookie
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith(identityCookieName))
|
||||
|
||||
identity.userId =
|
||||
options?.identityStorage === 'none'
|
||||
? undefined
|
||||
: identityCookie
|
||||
? decodeURIComponent(identityCookie.slice(identityCookieName.length))
|
||||
: undefined
|
||||
}),
|
||||
setOptOut: vi.fn(),
|
||||
track: vi.fn((eventName: string, properties: Record<string, unknown>) => {
|
||||
trackedEvents.push({ eventName, properties, userId: identity.userId })
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
mockConfig: {
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
},
|
||||
mockCreateInstance: vi.fn(() => mockWebAppClient),
|
||||
mockWebAppClient,
|
||||
mockIdentity: identity,
|
||||
mockTrackedEvents: trackedEvents,
|
||||
}
|
||||
})
|
||||
|
||||
let ensureWebAppAmplitudeInitialized: typeof import('../web-app-client').ensureWebAppAmplitudeInitialized
|
||||
let sendWebAppAmplitudeEvent: typeof import('../web-app-client').sendWebAppAmplitudeEvent
|
||||
let setWebAppAmplitudeOptOut: typeof import('../web-app-client').setWebAppAmplitudeOptOut
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get AMPLITUDE_API_KEY() {
|
||||
return mockConfig.AMPLITUDE_API_KEY
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
createInstance: mockCreateInstance,
|
||||
}))
|
||||
|
||||
describe('WebApp Amplitude client', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
document.cookie = `${CONSOLE_IDENTITY_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
|
||||
mockConfig.AMPLITUDE_API_KEY = TEST_API_KEY
|
||||
mockIdentity.userId = undefined
|
||||
mockTrackedEvents.length = 0
|
||||
;({ ensureWebAppAmplitudeInitialized, sendWebAppAmplitudeEvent, setWebAppAmplitudeOptOut } =
|
||||
await import('../web-app-client'))
|
||||
})
|
||||
|
||||
it('loads the SDK lazily and initializes one dedicated instance', async () => {
|
||||
expect(mockCreateInstance).not.toHaveBeenCalled()
|
||||
|
||||
await Promise.all([ensureWebAppAmplitudeInitialized(), ensureWebAppAmplitudeInitialized()])
|
||||
|
||||
expect(mockCreateInstance).toHaveBeenCalledTimes(1)
|
||||
expect(mockWebAppClient.init).toHaveBeenCalledTimes(1)
|
||||
expect(mockWebAppClient.init).toHaveBeenCalledWith('test-api-key', {
|
||||
autocapture: false,
|
||||
defaultTracking: false,
|
||||
fetchRemoteConfig: false,
|
||||
flushQueueSize: 1,
|
||||
identityStorage: 'none',
|
||||
instanceName: 'webapp',
|
||||
optOut: true,
|
||||
storageProvider: expect.any(Object),
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the first consented event while the SDK initializes', async () => {
|
||||
setWebAppAmplitudeOptOut(false)
|
||||
const pendingEvent = sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'agent-v2' })
|
||||
|
||||
expect(mockWebAppClient.track).not.toHaveBeenCalled()
|
||||
await pendingEvent
|
||||
|
||||
expect(mockWebAppClient.init).toHaveBeenCalledWith(
|
||||
TEST_API_KEY,
|
||||
expect.objectContaining({ optOut: false }),
|
||||
)
|
||||
expect(mockWebAppClient.setOptOut).not.toHaveBeenCalled()
|
||||
expect(mockWebAppClient.track).toHaveBeenCalledWith('webapp_run', {
|
||||
app_mode: 'agent-v2',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a pending event when consent is withdrawn before initialization completes', async () => {
|
||||
setWebAppAmplitudeOptOut(false)
|
||||
const pendingEvent = sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'workflow' })
|
||||
setWebAppAmplitudeOptOut(true)
|
||||
|
||||
await pendingEvent
|
||||
|
||||
expect(mockWebAppClient.init).toHaveBeenCalledWith(
|
||||
TEST_API_KEY,
|
||||
expect.objectContaining({ optOut: true }),
|
||||
)
|
||||
expect(mockWebAppClient.setOptOut).not.toHaveBeenCalled()
|
||||
expect(mockWebAppClient.track).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not inherit the console identity or unsent-event queue', async () => {
|
||||
document.cookie = `${CONSOLE_IDENTITY_COOKIE_NAME}=${encodeURIComponent('builder@example.com')}; path=/`
|
||||
localStorage.setItem(
|
||||
CONSOLE_UNSENT_EVENTS_KEY,
|
||||
JSON.stringify([{ event_type: 'console_event', user_id: 'builder@example.com' }]),
|
||||
)
|
||||
|
||||
setWebAppAmplitudeOptOut(false)
|
||||
await sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'workflow' })
|
||||
|
||||
expect(mockTrackedEvents).toEqual([
|
||||
{
|
||||
eventName: 'webapp_run',
|
||||
properties: { app_mode: 'workflow' },
|
||||
userId: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const initOptions = mockWebAppClient.init.mock.calls[0]?.[1]
|
||||
const storageProvider = initOptions?.storageProvider
|
||||
expect(storageProvider).toBeDefined()
|
||||
await expect(storageProvider?.get(CONSOLE_UNSENT_EVENTS_KEY)).resolves.toBeUndefined()
|
||||
|
||||
await storageProvider?.set(CONSOLE_UNSENT_EVENTS_KEY, [])
|
||||
await expect(storageProvider?.get(CONSOLE_UNSENT_EVENTS_KEY)).resolves.toEqual([])
|
||||
expect(localStorage.getItem(CONSOLE_UNSENT_EVENTS_KEY)).not.toBeNull()
|
||||
expect(localStorage).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not initialize when Amplitude is disabled', async () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
|
||||
await ensureWebAppAmplitudeInitialized()
|
||||
await sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'chatflow' })
|
||||
|
||||
expect(mockWebAppClient.init).not.toHaveBeenCalled()
|
||||
expect(mockWebAppClient.track).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ const getEnglishPageName = (pathname: string): string => {
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
apps: 'Studio',
|
||||
agents: 'Agents',
|
||||
datasets: 'Knowledge',
|
||||
explore: 'Explore',
|
||||
tools: 'Tools',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { coordinateRegistrationConsent } from './registration-tracking'
|
||||
|
||||
export function RegistrationConsentCoordinator() {
|
||||
const consent = useAnalyticsConsent()
|
||||
|
||||
useEffect(() => {
|
||||
coordinateRegistrationConsent(consent)
|
||||
}, [consent])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
|
||||
export const OAUTH_REGISTRATION_GA_SENT_KEY = 'oauth_registration_ga_sent'
|
||||
const FLUSH_RETRY_DELAYS_MS = [1000, 4000, 16000] as const
|
||||
|
||||
export const REGISTRATION_METHODS = ['email', 'oauth'] as const
|
||||
|
||||
export const ATTRIBUTION_KEYS = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'slug',
|
||||
] as const
|
||||
|
||||
export type RegistrationMethod = (typeof REGISTRATION_METHODS)[number]
|
||||
export type RegistrationAttribution = Partial<Record<(typeof ATTRIBUTION_KEYS)[number], string>>
|
||||
|
||||
export type RegistrationIntent = {
|
||||
registrationId: string
|
||||
occurredAt: number
|
||||
method: RegistrationMethod
|
||||
attribution: RegistrationAttribution
|
||||
}
|
||||
|
||||
let registrationDeliveryGeneration = 0
|
||||
let flushRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let flushRetryAttempt = 0
|
||||
|
||||
export const getRegistrationSessionStorage = (): Storage | null => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.sessionStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const removeStoredRegistrationMarker = (storage = getRegistrationSessionStorage()) => {
|
||||
try {
|
||||
storage?.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const hasSentOAuthRegistrationGA = () => {
|
||||
try {
|
||||
return getRegistrationSessionStorage()?.getItem(OAUTH_REGISTRATION_GA_SENT_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const markOAuthRegistrationGASent = () => {
|
||||
try {
|
||||
getRegistrationSessionStorage()?.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const clearOAuthRegistrationGAGuard = () => {
|
||||
try {
|
||||
getRegistrationSessionStorage()?.removeItem(OAUTH_REGISTRATION_GA_SENT_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const getRegistrationDeliveryGeneration = () => registrationDeliveryGeneration
|
||||
|
||||
export const clearRegistrationFlushRetry = () => {
|
||||
if (flushRetryTimer !== null) {
|
||||
clearTimeout(flushRetryTimer)
|
||||
flushRetryTimer = null
|
||||
}
|
||||
flushRetryAttempt = 0
|
||||
}
|
||||
|
||||
export const scheduleRegistrationFlushRetry = (runFlush: () => void) => {
|
||||
if (flushRetryAttempt >= FLUSH_RETRY_DELAYS_MS.length) return
|
||||
|
||||
const delay = FLUSH_RETRY_DELAYS_MS[flushRetryAttempt]
|
||||
flushRetryAttempt += 1
|
||||
const generation = registrationDeliveryGeneration
|
||||
if (flushRetryTimer !== null) clearTimeout(flushRetryTimer)
|
||||
|
||||
flushRetryTimer = setTimeout(() => {
|
||||
flushRetryTimer = null
|
||||
if (generation !== registrationDeliveryGeneration) return
|
||||
runFlush()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export const invalidateRegistrationDeliveryState = () => {
|
||||
registrationDeliveryGeneration += 1
|
||||
clearRegistrationFlushRetry()
|
||||
removeStoredRegistrationMarker()
|
||||
}
|
||||
|
||||
export const discardRegistrationSessionState = () => {
|
||||
invalidateRegistrationDeliveryState()
|
||||
clearOAuthRegistrationGAGuard()
|
||||
}
|
||||
@@ -1,38 +1,121 @@
|
||||
import type {
|
||||
RegistrationAttribution,
|
||||
RegistrationIntent,
|
||||
RegistrationMethod,
|
||||
} from './registration-session-state'
|
||||
import type { AnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { getIsAmplitudeInitialized } from './init'
|
||||
import {
|
||||
ATTRIBUTION_KEYS,
|
||||
clearRegistrationFlushRetry,
|
||||
getRegistrationDeliveryGeneration,
|
||||
getRegistrationSessionStorage,
|
||||
invalidateRegistrationDeliveryState,
|
||||
REGISTRATION_METHODS,
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
removeStoredRegistrationMarker,
|
||||
scheduleRegistrationFlushRetry,
|
||||
} from './registration-session-state'
|
||||
import { trackEvent } from './utils'
|
||||
|
||||
/**
|
||||
* Storage key for a registration success event that is waiting to be sent to
|
||||
* Amplitude until a user ID has been attached.
|
||||
*/
|
||||
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
|
||||
const REGISTRATION_MARKER_VERSION = 2
|
||||
const REGISTRATION_MARKER_TTL_MS = 24 * 60 * 60 * 1000
|
||||
// Browser clocks may be corrected between registration and delivery. Permit a small
|
||||
// correction, but reject timestamps far enough ahead to corrupt Amplitude ordering.
|
||||
const REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS = 5 * 60 * 1000
|
||||
const SUCCESSFUL_TRACK_RESULT_MIN = 200
|
||||
const SUCCESSFUL_TRACK_RESULT_MAX = 299
|
||||
|
||||
type RegistrationMethod = 'email' | 'oauth'
|
||||
const REGISTRATION_EVENT_NAMES = [
|
||||
'user_registration_success',
|
||||
'user_registration_success_with_utm',
|
||||
] as const
|
||||
|
||||
type PendingRegistrationSuccessEvent = {
|
||||
eventName: string
|
||||
properties: Record<string, unknown>
|
||||
type RegistrationEventName = (typeof REGISTRATION_EVENT_NAMES)[number]
|
||||
|
||||
type PendingRegistrationSuccessEvent = RegistrationIntent & {
|
||||
version: typeof REGISTRATION_MARKER_VERSION
|
||||
expiresAt: number
|
||||
eventName: RegistrationEventName
|
||||
}
|
||||
|
||||
const getSessionStorage = (): Storage | null => {
|
||||
let registrationSnapshot = 0
|
||||
let activeFlush: { generation: number; promise: Promise<void> } | null = null
|
||||
const registrationListeners = new Set<() => void>()
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const isRegistrationMethod = (value: unknown): value is RegistrationMethod =>
|
||||
typeof value === 'string' && REGISTRATION_METHODS.includes(value as RegistrationMethod)
|
||||
|
||||
const isRegistrationEventName = (value: unknown): value is RegistrationEventName =>
|
||||
typeof value === 'string' && REGISTRATION_EVENT_NAMES.includes(value as RegistrationEventName)
|
||||
|
||||
const notifyRegistrationMarkerStored = () => {
|
||||
registrationSnapshot += 1
|
||||
registrationListeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
const createRegistrationId = () => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.sessionStorage
|
||||
return globalThis.crypto.randomUUID()
|
||||
} catch {
|
||||
return null
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeRegistrationAttribution = (
|
||||
value?: Record<string, unknown> | null,
|
||||
): RegistrationAttribution | null => {
|
||||
if (!value) return null
|
||||
|
||||
const attribution: RegistrationAttribution = {}
|
||||
ATTRIBUTION_KEYS.forEach((key) => {
|
||||
const item = value[key]
|
||||
if (typeof item !== 'string') return
|
||||
|
||||
const normalized = item.trim()
|
||||
if (normalized) attribution[key] = normalized
|
||||
})
|
||||
|
||||
return Object.keys(attribution).length ? attribution : null
|
||||
}
|
||||
|
||||
const createRegistrationIntent = (
|
||||
method: RegistrationMethod,
|
||||
utmInfo?: Record<string, unknown> | null,
|
||||
): RegistrationIntent => ({
|
||||
registrationId: createRegistrationId(),
|
||||
occurredAt: Date.now(),
|
||||
method,
|
||||
attribution: normalizeRegistrationAttribution(utmInfo) ?? {},
|
||||
})
|
||||
|
||||
const storeRegistrationIntent = (intent: RegistrationIntent) => {
|
||||
const storage = getRegistrationSessionStorage()
|
||||
if (!storage) return false
|
||||
|
||||
const pending: PendingRegistrationSuccessEvent = {
|
||||
...intent,
|
||||
version: REGISTRATION_MARKER_VERSION,
|
||||
expiresAt: intent.occurredAt + REGISTRATION_MARKER_TTL_MS,
|
||||
eventName: Object.keys(intent.attribution).length
|
||||
? 'user_registration_success_with_utm'
|
||||
: 'user_registration_success',
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
|
||||
clearRegistrationFlushRetry()
|
||||
notifyRegistrationMarkerStored()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a registration success event after analytics consent so it can be sent
|
||||
* to Amplitude *after* the user ID is attached (see `flushRegistrationSuccess`).
|
||||
*
|
||||
* Amplitude attributes events to whatever identity is active when `track` runs. At
|
||||
* registration time the client does not yet know the user ID, so firing the event
|
||||
* immediately records it under an anonymous profile. We persist the event here and
|
||||
* replay it once `setUserId` runs in the bootstrap effects after the redirect. An
|
||||
* event produced before analytics consent is granted is dropped instead of queued.
|
||||
*/
|
||||
export const rememberRegistrationSuccess = ({
|
||||
method,
|
||||
utmInfo,
|
||||
@@ -40,49 +123,157 @@ export const rememberRegistrationSuccess = ({
|
||||
method: RegistrationMethod
|
||||
utmInfo?: Record<string, unknown> | null
|
||||
}) => {
|
||||
if (getAnalyticsConsent() !== 'granted') return
|
||||
|
||||
const storage = getSessionStorage()
|
||||
if (!storage) return
|
||||
|
||||
const pending: PendingRegistrationSuccessEvent = {
|
||||
eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success',
|
||||
properties: { method, ...utmInfo },
|
||||
const consent = getAnalyticsConsent()
|
||||
if (consent === 'denied' || consent === 'disabled') {
|
||||
invalidateRegistrationDeliveryState()
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
|
||||
} catch {}
|
||||
// Persist even while consent is unknown. Flush waits for grant + Amplitude init
|
||||
// + user identity, so a later full-page redirect still has the marker.
|
||||
return storeRegistrationIntent(createRegistrationIntent(method, utmInfo))
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a previously remembered registration success event to Amplitude.
|
||||
*
|
||||
* MUST be called after `setUserId` so the event lands on the identified user profile.
|
||||
* No-op when nothing is pending. The pending entry is removed before tracking so the
|
||||
* event fires at most once even if this runs multiple times.
|
||||
*/
|
||||
export const flushRegistrationSuccess = () => {
|
||||
const storage = getSessionStorage()
|
||||
export const coordinateRegistrationConsent = (consent: AnalyticsConsent) => {
|
||||
if (consent === 'denied' || consent === 'disabled') invalidateRegistrationDeliveryState()
|
||||
}
|
||||
|
||||
export const subscribeRegistrationSuccess = (listener: () => void) => {
|
||||
registrationListeners.add(listener)
|
||||
return () => registrationListeners.delete(listener)
|
||||
}
|
||||
|
||||
export const getRegistrationSuccessSnapshot = () => registrationSnapshot
|
||||
|
||||
const isRegistrationAttribution = (value: unknown): value is RegistrationAttribution => {
|
||||
if (!isRecord(value)) return false
|
||||
|
||||
return Object.entries(value).every(
|
||||
([key, item]) =>
|
||||
ATTRIBUTION_KEYS.includes(key as (typeof ATTRIBUTION_KEYS)[number]) &&
|
||||
typeof item === 'string' &&
|
||||
Boolean(item.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
const parsePendingRegistration = (raw: string): PendingRegistrationSuccessEvent | null => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!isRecord(value)) return null
|
||||
if (value.version !== REGISTRATION_MARKER_VERSION) return null
|
||||
if (typeof value.registrationId !== 'string' || !value.registrationId) return null
|
||||
if (typeof value.occurredAt !== 'number' || !Number.isFinite(value.occurredAt)) return null
|
||||
if (typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt)) return null
|
||||
if (value.expiresAt !== value.occurredAt + REGISTRATION_MARKER_TTL_MS) return null
|
||||
if (!isRegistrationEventName(value.eventName)) return null
|
||||
if (!isRegistrationMethod(value.method)) return null
|
||||
if (!isRegistrationAttribution(value.attribution)) return null
|
||||
|
||||
const hasAttribution = Object.keys(value.attribution).length > 0
|
||||
if (hasAttribution !== (value.eventName === 'user_registration_success_with_utm')) return null
|
||||
|
||||
return value as PendingRegistrationSuccessEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const runRegistrationFlush = async (generation: number) => {
|
||||
const isStale = () => generation !== getRegistrationDeliveryGeneration()
|
||||
if (isStale()) return
|
||||
|
||||
const consent = getAnalyticsConsent()
|
||||
if (consent === 'unknown') return
|
||||
|
||||
const storage = getRegistrationSessionStorage()
|
||||
if (!storage) return
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
} catch {
|
||||
if (consent === 'denied' || consent === 'disabled') {
|
||||
invalidateRegistrationDeliveryState()
|
||||
return
|
||||
}
|
||||
if (!getIsAmplitudeInitialized()) return
|
||||
|
||||
if (!raw) return
|
||||
while (true) {
|
||||
if (isStale()) return
|
||||
|
||||
try {
|
||||
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
} catch {}
|
||||
let raw: string | null
|
||||
try {
|
||||
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!raw) return
|
||||
|
||||
if (getAnalyticsConsent() !== 'granted') return
|
||||
const pending = parsePendingRegistration(raw)
|
||||
const now = Date.now()
|
||||
if (
|
||||
!pending ||
|
||||
pending.expiresAt <= now ||
|
||||
pending.occurredAt > now + REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS
|
||||
) {
|
||||
removeStoredRegistrationMarker(storage)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
|
||||
if (pending?.eventName) trackEvent(pending.eventName, pending.properties)
|
||||
} catch {}
|
||||
let trackResult: ReturnType<typeof trackEvent>
|
||||
try {
|
||||
trackResult = trackEvent(
|
||||
pending.eventName,
|
||||
{
|
||||
method: pending.method,
|
||||
...pending.attribution,
|
||||
registration_id: pending.registrationId,
|
||||
event_version: REGISTRATION_MARKER_VERSION,
|
||||
tracking_contract_version: 'consent_wait_v2',
|
||||
},
|
||||
{
|
||||
insert_id: pending.registrationId,
|
||||
time: pending.occurredAt,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!trackResult) return
|
||||
|
||||
let acknowledged = false
|
||||
try {
|
||||
const result: { code?: unknown } = await trackResult.promise
|
||||
acknowledged =
|
||||
typeof result.code === 'number' &&
|
||||
result.code >= SUCCESSFUL_TRACK_RESULT_MIN &&
|
||||
result.code <= SUCCESSFUL_TRACK_RESULT_MAX
|
||||
} catch {}
|
||||
if (isStale()) return
|
||||
|
||||
let currentRaw: string | null
|
||||
try {
|
||||
currentRaw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (currentRaw !== raw) continue
|
||||
if (!acknowledged) {
|
||||
scheduleRegistrationFlushRetry(() => {
|
||||
void flushRegistrationSuccess()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
clearRegistrationFlushRetry()
|
||||
removeStoredRegistrationMarker(storage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function flushRegistrationSuccess() {
|
||||
const generation = getRegistrationDeliveryGeneration()
|
||||
if (activeFlush?.generation === generation) return activeFlush.promise
|
||||
|
||||
const promise = runRegistrationFlush(generation).finally(() => {
|
||||
if (activeFlush?.promise === promise) activeFlush = null
|
||||
})
|
||||
activeFlush = { generation, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
@@ -9,8 +9,13 @@ const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplit
|
||||
* @param eventName Event name
|
||||
* @param eventProperties Event properties (optional)
|
||||
*/
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, unknown>) => {
|
||||
export const trackEvent = (
|
||||
eventName: string,
|
||||
eventProperties?: Record<string, unknown>,
|
||||
eventOptions?: amplitude.Types.EventOptions,
|
||||
) => {
|
||||
if (!canUseAmplitude()) return
|
||||
if (eventOptions) return amplitude.track(eventName, eventProperties, eventOptions)
|
||||
return amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
|
||||
|
||||
85
web/app/components/base/amplitude/web-app-client.ts
Normal file
85
web/app/components/base/amplitude/web-app-client.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Types } from '@amplitude/analytics-browser'
|
||||
import type { WebAppEventName, WebAppEventProperties } from './web-app-event'
|
||||
import { AMPLITUDE_API_KEY } from '@/config'
|
||||
|
||||
type WebAppAmplitudeClient = ReturnType<
|
||||
(typeof import('@amplitude/analytics-browser'))['createInstance']
|
||||
>
|
||||
type WebAppAmplitudeStorageProvider = NonNullable<Types.BrowserOptions['storageProvider']>
|
||||
|
||||
const webAppAmplitudeEventQueue = new Map<string, Types.Event[]>()
|
||||
|
||||
const webAppAmplitudeStorageProvider: WebAppAmplitudeStorageProvider = {
|
||||
async isEnabled() {
|
||||
return true
|
||||
},
|
||||
async get(key) {
|
||||
return webAppAmplitudeEventQueue.get(key)
|
||||
},
|
||||
async getRaw(key) {
|
||||
const value = webAppAmplitudeEventQueue.get(key)
|
||||
return value ? JSON.stringify(value) : undefined
|
||||
},
|
||||
async set(key, value) {
|
||||
webAppAmplitudeEventQueue.set(key, value)
|
||||
},
|
||||
async remove(key) {
|
||||
webAppAmplitudeEventQueue.delete(key)
|
||||
},
|
||||
async reset() {
|
||||
webAppAmplitudeEventQueue.clear()
|
||||
},
|
||||
}
|
||||
|
||||
let webAppAmplitude: WebAppAmplitudeClient | null = null
|
||||
let webAppAmplitudeInitialization: Promise<WebAppAmplitudeClient | null> | null = null
|
||||
let shouldWebAppAmplitudeOptOut = true
|
||||
|
||||
async function initializeWebAppAmplitude(): Promise<WebAppAmplitudeClient | null> {
|
||||
try {
|
||||
const { createInstance } = await import('@amplitude/analytics-browser')
|
||||
const client = createInstance()
|
||||
|
||||
client.init(AMPLITUDE_API_KEY, {
|
||||
autocapture: false,
|
||||
defaultTracking: false,
|
||||
fetchRemoteConfig: false,
|
||||
flushQueueSize: 1,
|
||||
identityStorage: 'none',
|
||||
instanceName: 'webapp',
|
||||
optOut: shouldWebAppAmplitudeOptOut,
|
||||
storageProvider: webAppAmplitudeStorageProvider,
|
||||
})
|
||||
webAppAmplitude = client
|
||||
|
||||
return client
|
||||
} catch {
|
||||
webAppAmplitudeInitialization = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureWebAppAmplitudeInitialized() {
|
||||
if (!AMPLITUDE_API_KEY) return Promise.resolve(null)
|
||||
|
||||
if (!webAppAmplitudeInitialization) webAppAmplitudeInitialization = initializeWebAppAmplitude()
|
||||
|
||||
return webAppAmplitudeInitialization
|
||||
}
|
||||
|
||||
export function setWebAppAmplitudeOptOut(optOut: boolean) {
|
||||
shouldWebAppAmplitudeOptOut = optOut
|
||||
webAppAmplitude?.setOptOut(optOut)
|
||||
}
|
||||
|
||||
export async function sendWebAppAmplitudeEvent<EventName extends WebAppEventName>(
|
||||
eventName: EventName,
|
||||
properties: WebAppEventProperties[EventName],
|
||||
) {
|
||||
if (shouldWebAppAmplitudeOptOut) return
|
||||
|
||||
const client = await ensureWebAppAmplitudeInitialized()
|
||||
if (!client || shouldWebAppAmplitudeOptOut) return
|
||||
|
||||
client.track(eventName, properties)
|
||||
}
|
||||
29
web/app/components/base/amplitude/web-app-event.ts
Normal file
29
web/app/components/base/amplitude/web-app-event.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type WebAppEventProperties = {
|
||||
webapp_run: {
|
||||
app_mode: string
|
||||
}
|
||||
}
|
||||
|
||||
export type WebAppEventName = keyof WebAppEventProperties
|
||||
|
||||
export type WebAppEventTracker = <EventName extends WebAppEventName>(
|
||||
eventName: EventName,
|
||||
properties: WebAppEventProperties[EventName],
|
||||
) => void
|
||||
|
||||
let activeTracker: WebAppEventTracker | null = null
|
||||
|
||||
export function registerWebAppEventTracker(tracker: WebAppEventTracker) {
|
||||
activeTracker = tracker
|
||||
|
||||
return () => {
|
||||
if (activeTracker === tracker) activeTracker = null
|
||||
}
|
||||
}
|
||||
|
||||
export function trackWebAppEvent<EventName extends WebAppEventName>(
|
||||
eventName: EventName,
|
||||
properties: WebAppEventProperties[EventName],
|
||||
) {
|
||||
activeTracker?.(eventName, properties)
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
This module owns CookieYes consent state and the Dify Cloud analytics boundary.
|
||||
|
||||
- `request-boundary.ts` decides whether a request is eligible for Cloud analytics before client code mounts.
|
||||
- `request-boundary.ts` decides whether the deployment, environment, and host are eligible for Cloud analytics before client code mounts.
|
||||
- `cloud-analytics-layout-boundary.tsx` uses the active Next.js route group to select the console or WebApp analytics scripts and runtime without maintaining URL path exclusions. Google Analytics is mounted only for the console branch.
|
||||
- `cookieyes-consent-bridge.tsx` is the only CookieYes-to-application state adapter.
|
||||
- `consent-store.ts` owns the client consent snapshot; analytics consumers must gate SDK activation or event emission on that snapshot.
|
||||
- `cloud-analytics.tsx` gates analytics scripts and runtime mounting by deployment, environment, host, and route.
|
||||
- `cloud-analytics-runtime.tsx` mounts the consent bridge and client analytics consumers.
|
||||
- `cloud-analytics.tsx` gates analytics scripts and runtime mounting by deployment, environment, and host.
|
||||
- `console-analytics-runtime.tsx` mounts the full Amplitude configuration and external-attribution consumers.
|
||||
- `web-app-analytics-runtime.tsx` mounts consent and the dedicated custom-event-only Amplitude provider. It must not mount Google Analytics, automatic Amplitude tracking, session replay, or external attribution.
|
||||
|
||||
Amplitude, Google Analytics, attribution, and other consumers must depend on this boundary instead of reading CookieYes state directly.
|
||||
Amplitude, Google Analytics, attribution, and other consumers must depend on these boundaries instead of reading CookieYes state directly. WebApp components must use `trackWebAppEvent` rather than the console `trackEvent` API.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { REGISTRATION_SUCCESS_STORAGE_KEY } from '../../amplitude/registration-session-state'
|
||||
import {
|
||||
coordinateRegistrationConsent,
|
||||
rememberRegistrationSuccess,
|
||||
} from '../../amplitude/registration-tracking'
|
||||
import { AnalyticsDisabled } from '../analytics-disabled'
|
||||
import { getAnalyticsConsent, setAnalyticsConsent } from '../consent-store'
|
||||
|
||||
describe('AnalyticsDisabled', () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear()
|
||||
coordinateRegistrationConsent('denied')
|
||||
setAnalyticsConsent('granted')
|
||||
})
|
||||
|
||||
it('terminally discards a pending registration marker', async () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull()
|
||||
|
||||
render(<AnalyticsDisabled />)
|
||||
|
||||
await waitFor(() => expect(getAnalyticsConsent()).toBe('disabled'))
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ConsoleAnalyticsRuntime } from '../console-analytics-runtime'
|
||||
import { WebAppAnalyticsRuntime } from '../web-app-analytics-runtime'
|
||||
|
||||
vi.mock('../cookieyes-consent-bridge', () => ({
|
||||
CookieYesConsentBridge: () => <span data-testid="cookieyes-consent-bridge" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
default: () => <span data-testid="console-amplitude-provider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/WebAppAmplitudeProvider', () => ({
|
||||
WebAppAmplitudeProvider: () => <span data-testid="web-app-amplitude-provider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude/registration-consent-coordinator', () => ({
|
||||
RegistrationConsentCoordinator: () => <span data-testid="registration-consent-coordinator" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/external-attribution-recorder', () => ({
|
||||
default: () => <span data-testid="external-attribution-recorder" />,
|
||||
}))
|
||||
|
||||
describe('analytics runtimes', () => {
|
||||
it('mounts full analytics consumers for the console', () => {
|
||||
render(<ConsoleAnalyticsRuntime />)
|
||||
|
||||
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('console-amplitude-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('registration-consent-coordinator')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('web-app-amplitude-provider')).toBeNull()
|
||||
})
|
||||
|
||||
it('mounts only consent and custom-event Amplitude for WebApps', () => {
|
||||
render(<WebAppAnalyticsRuntime />)
|
||||
|
||||
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('web-app-amplitude-provider')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('console-amplitude-provider')).toBeNull()
|
||||
expect(screen.queryByTestId('registration-consent-coordinator')).toBeNull()
|
||||
expect(screen.queryByTestId('external-attribution-recorder')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CloudAnalyticsLayoutBoundary } from '../cloud-analytics-layout-boundary'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
layoutSegments: ['(commonLayout)', 'agents'],
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockState.layoutSegments,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/script', () => ({
|
||||
default: ({
|
||||
id,
|
||||
strategy,
|
||||
src,
|
||||
nonce,
|
||||
children,
|
||||
}: {
|
||||
id?: string
|
||||
strategy?: string
|
||||
src?: string
|
||||
nonce?: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<script
|
||||
data-id={id ?? ''}
|
||||
data-inline={typeof children === 'string' ? children : ''}
|
||||
data-nonce={nonce ?? ''}
|
||||
data-src={src ?? ''}
|
||||
data-strategy={strategy ?? ''}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../console-analytics-runtime', () => ({
|
||||
ConsoleAnalyticsRuntime: () => <span data-testid="console-analytics-runtime" />,
|
||||
}))
|
||||
|
||||
vi.mock('../web-app-analytics-runtime', () => ({
|
||||
WebAppAnalyticsRuntime: () => <span data-testid="web-app-analytics-runtime" />,
|
||||
}))
|
||||
|
||||
describe('CloudAnalyticsLayoutBoundary', () => {
|
||||
beforeEach(() => {
|
||||
mockState.layoutSegments = ['(commonLayout)', 'agents']
|
||||
})
|
||||
|
||||
it('mounts analytics for the console layout, including the agents list', () => {
|
||||
const { container } = render(
|
||||
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
|
||||
)
|
||||
|
||||
const scripts = Array.from(container.querySelectorAll('script'))
|
||||
const scriptIds = scripts.map(
|
||||
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
|
||||
)
|
||||
expect(scriptIds).toEqual([
|
||||
'google-consent-defaults',
|
||||
'cookieyes',
|
||||
'google-analytics',
|
||||
'google-analytics-init',
|
||||
])
|
||||
expect(container.querySelector('script[data-id="cookieyes"]')).toHaveAttribute(
|
||||
'data-src',
|
||||
'https://cdn-cookieyes.com/client_data/site-key/script.js',
|
||||
)
|
||||
expect(screen.getByTestId('console-analytics-runtime')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('web-app-analytics-runtime')).toBeNull()
|
||||
})
|
||||
|
||||
it('mounts only consent and custom-event analytics for the published WebApp layout', () => {
|
||||
mockState.layoutSegments = ['(shareLayout)', 'agent', 'token']
|
||||
|
||||
const { container } = render(
|
||||
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
|
||||
)
|
||||
|
||||
const scriptIds = Array.from(container.querySelectorAll('script')).map(
|
||||
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
|
||||
)
|
||||
expect(scriptIds).toEqual(['cookieyes'])
|
||||
expect(screen.queryByTestId('console-analytics-runtime')).toBeNull()
|
||||
expect(screen.getByTestId('web-app-analytics-runtime')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches analytics runtimes when navigation enters the WebApp layout', () => {
|
||||
const { rerender } = render(
|
||||
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
|
||||
)
|
||||
expect(screen.getByTestId('console-analytics-runtime')).toBeInTheDocument()
|
||||
|
||||
mockState.layoutSegments = ['(shareLayout)', 'workflow', 'token']
|
||||
rerender(<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />)
|
||||
|
||||
expect(screen.queryByTestId('console-analytics-runtime')).toBeNull()
|
||||
expect(screen.getByTestId('web-app-analytics-runtime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CloudAnalyticsRuntime } from '../cloud-analytics-runtime'
|
||||
|
||||
const mockState = vi.hoisted(() => ({ pathname: '/signin' }))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockState.pathname,
|
||||
}))
|
||||
|
||||
vi.mock('../cookieyes-consent-bridge', () => ({
|
||||
CookieYesConsentBridge: () => <span data-testid="cookieyes-consent-bridge" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
default: ({ active }: { active: boolean }) => (
|
||||
<span data-active={String(active)} data-testid="amplitude-provider" />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/external-attribution-recorder', () => ({
|
||||
default: () => <span data-testid="external-attribution-recorder" />,
|
||||
}))
|
||||
|
||||
describe('CloudAnalyticsRuntime', () => {
|
||||
beforeEach(() => {
|
||||
mockState.pathname = '/signin'
|
||||
})
|
||||
|
||||
it('keeps consent active across first-party Cloud routes', () => {
|
||||
const { rerender } = render(<CloudAnalyticsRuntime />)
|
||||
|
||||
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
|
||||
|
||||
mockState.pathname = '/integrations'
|
||||
rerender(<CloudAnalyticsRuntime />)
|
||||
|
||||
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
|
||||
it('opts Amplitude out after a client transition into a share route', () => {
|
||||
const { rerender } = render(<CloudAnalyticsRuntime />)
|
||||
|
||||
mockState.pathname = '/workflow/token'
|
||||
rerender(<CloudAnalyticsRuntime />)
|
||||
|
||||
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user