Compare commits

...

26 Commits

Author SHA1 Message Date
Yansong Zhang
0d24f2863d style(agent): apply automated formatting 2026-09-01 13:52:18 +08:00
Yansong Zhang
0188ab7121 test(agent): specify integration shell mode 2026-09-01 13:49:07 +08:00
Yansong Zhang
5bbd5feacf fix(agent): bound Redis run event streams 2026-09-01 13:48:44 +08:00
Yansong Zhang
d080f1f0c8 docs(api): sync trigger workflow error contract 2026-08-31 17:31:31 +08:00
autofix-ci[bot]
04ea282b98 [autofix.ci] apply automated fixes 2026-08-31 17:31:26 +08:00
Yansong Zhang
924ca907c2 fix(workflow): block manual runs for trigger workflows 2026-08-31 17:31:19 +08:00
林玮 (Jade Lin)
21c54505d1 fix(auth): normalize Gmail registration emails (#41249)
(cherry picked from commit f5aaa50a30)
2026-08-31 14:21:36 +08:00
Wu Tianwei
6ac5d49672 fix(rbac): grant agent access to all legacy roles (#41511)
(cherry picked from commit 7167d7d662)
2026-08-31 12:10:24 +08:00
Coding On Star
b838c4b34e fix(web): make registration tracking consent-safe and retryable (#41365)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
(cherry picked from commit c6ca395042)
2026-08-31 12:10:24 +08:00
非法操作
dd89355ad5 fix(web): show DSL import warning details (#41502)
(cherry picked from commit b70ad7d424)
2026-08-31 12:10:24 +08:00
zyssyz123
5e9c15a120 fix(agent): reject incompatible session snapshots (#41447)
(cherry picked from commit ad86351cc1)
2026-08-31 12:10:24 +08:00
lei_lei
8adbd39799 fix(api): honor DELETE JSON body in model_validate (#41288) 2026-08-28 11:02:48 +08:00
-LAN-
3991538c2a fix(api): preserve tracing configuration boundaries (#41403) 2026-08-27 22:12:10 +08:00
yyh
91cfe7e389 fix: align documentation links with stable anchors (#41384)
(cherry picked from commit a38ddd9d02)
2026-08-27 17:18:23 +08:00
wangxiaolei
c5c32e657d chore: add permission check back (#41369) 2026-08-27 16:42:26 +08:00
Joel
3c4faa5173 feat: track inline agent and preview mode events (#41373)
(cherry picked from commit d94204ec33)
2026-08-27 16:25:19 +08:00
yyh
4919a9a7ef fix(web): refine agent configure section states (#41371)
(cherry picked from commit 59383e88c2)
2026-08-27 16:14:13 +08:00
Joel
04ba11dede fix: allow publishing workflows with legacy Agent nodes (#41368)
(cherry picked from commit 1be72af661)
2026-08-27 16:03:39 +08:00
非法操作
71a2e849fa fix(web): use management provider list for MCP integrations (#41360)
(cherry picked from commit 735d814dc0)
2026-08-27 15:09:41 +08:00
yyh
159e384af4 fix(web): route Agent V2 documentation links correctly (#41353)
(cherry picked from commit 85b4626ac6)
2026-08-27 14:33:08 +08:00
wangxiaolei
ae3503ce11 chore: remove permission check (#41349) 2026-08-27 13:50:47 +08:00
非法操作
f4e2c1b610 chore(web): remove retired forum entry (#41289)
(cherry picked from commit 6ba29b3f0c)
2026-08-26 15:37:42 +08:00
Joel
ba5d8f1855 fix: separate WebApp analytics from console page tracking (#41251)
(cherry picked from commit dfe396fb64)
2026-08-26 15:37:42 +08:00
yyh
59516de8a3 fix(web): upgrade Next.js to 16.3.3 (#41262)
(cherry picked from commit 36dfc5abd3)
2026-08-26 15:37:42 +08:00
yyh
41e52b2130 fix(web): restore AI credits icon (#41257)
(cherry picked from commit ed184a6632)
2026-08-26 15:37:42 +08:00
wangxiaolei
dfbb81140c feat: go to anything check enable skill (#41281) 2026-08-26 13:34:07 +08:00
189 changed files with 4394 additions and 911 deletions

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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."

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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):

View File

@@ -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:

View File

@@ -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."

View File

@@ -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:

View File

@@ -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."

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
*,

View File

@@ -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},

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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. | |

View File

@@ -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)

View File

@@ -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()

View File

@@ -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",

View File

@@ -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(

View 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

View File

@@ -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)

View File

@@ -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:
"""

View File

@@ -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] = [

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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']

View File

@@ -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),
],
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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),
],
)

View File

@@ -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

View File

@@ -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__)

View File

@@ -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

View File

@@ -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:

View File

@@ -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(),

View File

@@ -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

View File

@@ -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",

View File

@@ -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]

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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.

View File

@@ -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

View 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",
]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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:

View File

@@ -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())

View File

@@ -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())

View File

@@ -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

View File

@@ -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:

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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
View File

@@ -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'

View File

@@ -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

View 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()
})
})

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -25,6 +25,8 @@ export function AmplitudeProvider({
sessionReplaySampleRate,
})
setAmplitudeOptOut(false)
return () => setAmplitudeOptOut(true)
}, [active, consent, sessionReplaySampleRate])
return null

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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()
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}

View 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)
}

View 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)
}

View File

@@ -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.

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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