Compare commits

...
Author SHA1 Message Date
Yansong Zhang 42c3163f90 fix: wait for qdrant endpoint before activating tidb cluster 2026-05-07 09:47:36 +08:00
hj24 e100e96279 fix: remove graphon 2026-04-28 16:07:45 +08:00
hj24hj24Yansong Zhangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a03d5b8ed3 refactor: quota v3 integration (#35436)
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-28 10:51:46 +08:00
Yansong Zhang ef50e117da feat: tidb endpoint (#35158)
Backport langgenius/dify#35158 to hotfix branch: persist a per-cluster
qdrant endpoint on TidbAuthBinding so each tenant connects to the
qdrant gateway colocated with its TiDB Serverless cluster instead of a
single global TIDB_ON_QDRANT_URL, which fails for clusters in other
regions.

Adapted to the hotfix branch's pre-monorepo paths
(api/core/rag/datasource/vdb/tidb_on_qdrant/) and direct httpx usage.

Made-with: Cursor
2026-04-27 11:20:33 +08:00
非法操作 98e5387356 fix: web app user avatar display incorrect black (#34624) 2026-04-24 11:30:02 +08:00
Yansong Zhang a4cb47d6cd fix: resolve DetachedInstanceError on Google OAuth login
When account_integrates has no record, the email fallback path used
sessionmaker(db.engine).begin() which auto-commits on exit, expiring
all attributes on the Account object. Subsequent access to account.id
in get_join_tenants raises DetachedInstanceError.

Remove the independent session wrapper and let
get_account_by_email_with_case_fallback use db.session (request-scoped)
so the returned Account stays attached.

Cherry-picked from bcd738d2e6 on main (#34904).

Made-with: Cursor
2026-04-17 10:40:01 +08:00
hj24hj24autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1a9a1e821f refactor: enhance billing info response handling (#34340)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-13 17:43:48 +08:00
wangxiaoleihj24autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
c3e05045bb fix: fix qdrant delete size is too large (#35042)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-13 16:56:51 +08:00
hj24andhj24 b6420ec6de fix: optimize trigger long running read transactions (#35046) 2026-04-13 16:56:23 +08:00
Stephen Zhou 616d4c6fdb chore: update react & next version 2026-04-09 15:39:36 +08:00
非法操作andfatelei 167058ec51 fix: datasource api-key modal z-index incorrect (#34103) 2026-04-02 13:35:49 +08:00
wangxiaolei 0a27e38170 fix: fix online_drive is not a valid datasource_type (#34440) 2026-04-02 12:59:50 +08:00
wangxiaolei 5ab3526845 fix: sqlalchemy.exc.InvalidRequestError: Can't operate on closed tran… (#34407) 2026-04-01 21:44:05 +08:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
59639ca9b2 chore: bump Dify to 1.13.3 and sandbox to 0.2.13 (#34079)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-25 20:03:15 +08:00
Xin ZhangandGitHub 66b8c42a25 feat: add inner API endpoints for admin DSL import/export (#34059) 2026-03-25 19:48:53 +08:00
+3 449d8c7768 test(workflow-app): enhance unit tests for workflow components and hooks (#34065)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
Co-authored-by: Desel72 <pedroluiscolmenares722@gmail.com>
Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com>
Co-authored-by: Krishna Chaitanya <krishnabkc15@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 18:34:32 +08:00
92 changed files with 9375 additions and 1965 deletions
+1 -3
View File
@@ -4,7 +4,6 @@ import urllib.parse
import httpx
from flask import current_app, redirect, request
from flask_restx import Resource
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Unauthorized
from configs import dify_config
@@ -180,8 +179,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
account: Account | None = Account.get_by_openid(provider, user_info.id)
if not account:
with sessionmaker(db.engine).begin() as session:
account = AccountService.get_account_by_email_with_case_fallback(user_info.email, session=session)
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
return account
@@ -607,19 +607,15 @@ class PublishedRagPipelineApi(Resource):
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
rag_pipeline_service = RagPipelineService()
with Session(db.engine) as session:
pipeline = session.merge(pipeline)
workflow = rag_pipeline_service.publish_workflow(
session=session,
pipeline=pipeline,
account=current_user,
)
pipeline.is_published = True
pipeline.workflow_id = workflow.id
session.add(pipeline)
workflow_created_at = TimestampField().format(workflow.created_at)
session.commit()
workflow = rag_pipeline_service.publish_workflow(
session=db.session, # type: ignore[reportArgumentType,arg-type]
pipeline=pipeline,
account=current_user,
)
pipeline.is_published = True
pipeline.workflow_id = workflow.id
db.session.commit()
workflow_created_at = TimestampField().format(workflow.created_at)
return {
"result": "success",
+2
View File
@@ -16,12 +16,14 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
__all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_workspace",
@@ -0,0 +1 @@
+110
View File
@@ -0,0 +1,110 @@
"""Inner API endpoints for app DSL import/export.
Called by the enterprise admin-api service. Import requires ``creator_email``
to attribute the created app; workspace/membership validation is done by the
Go admin-api caller.
"""
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from extensions.ext_database import db
from models import Account, App
from models.account import AccountStatus
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
class InnerAppDSLImportPayload(BaseModel):
yaml_content: str = Field(description="YAML DSL content")
creator_email: str = Field(description="Email of the workspace member who will own the imported app")
name: str | None = Field(default=None, description="Override app name from DSL")
description: str | None = Field(default=None, description="Override app description from DSL")
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
class EnterpriseAppDSLImport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc("enterprise_app_dsl_import")
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
@inner_api_ns.doc(
responses={
200: "Import completed",
202: "Import pending (DSL version mismatch requires confirmation)",
400: "Import failed (business error)",
404: "Creator account not found or inactive",
}
)
def post(self, workspace_id: str):
"""Import a DSL into a workspace on behalf of a specified creator."""
args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {})
account = _get_active_account(args.creator_email)
if account is None:
return {"message": f"account '{args.creator_email}' not found or inactive"}, 404
account.set_tenant_id(workspace_id)
with Session(db.engine) as session:
dsl_service = AppDslService(session)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=args.yaml_content,
name=args.name,
description=args.description,
)
session.commit()
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
if result.status == ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
return result.model_dump(mode="json"), 200
@inner_api_ns.route("/enterprise/apps/<string:app_id>/dsl")
class EnterpriseAppDSLExport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_app_dsl_export",
responses={
200: "Export successful",
404: "App not found",
},
)
def get(self, app_id: str):
"""Export an app's DSL as YAML."""
include_secret = request.args.get("include_secret", "false").lower() == "true"
app_model = db.session.query(App).filter_by(id=app_id).first()
if not app_model:
return {"message": "app not found"}, 404
data = AppDslService.export_dsl(
app_model=app_model,
include_secret=include_secret,
)
return {"data": data}, 200
def _get_active_account(email: str) -> Account | None:
"""Look up an active account by email.
Workspace membership is already validated by the Go admin-api caller.
"""
account = db.session.query(Account).filter_by(email=email).first()
if account is None or account.status != AccountStatus.ACTIVE:
return None
return account
@@ -1,4 +1,5 @@
import json
import logging
import os
import uuid
from collections.abc import Generator, Iterable, Sequence
@@ -7,6 +8,8 @@ from typing import TYPE_CHECKING, Any, Union
import httpx
import qdrant_client
logger = logging.getLogger(__name__)
from flask import current_app
from httpx import DigestAuth
from pydantic import BaseModel
@@ -288,26 +291,27 @@ class TidbOnQdrantVector(BaseVector):
if not ids:
return
try:
filter = models.Filter(
must=[
models.FieldCondition(
key="metadata.doc_id",
match=models.MatchAny(any=ids),
),
],
)
self._client.delete(
collection_name=self._collection_name,
points_selector=FilterSelector(filter=filter),
)
except UnexpectedResponse as e:
# Collection does not exist, so return
if e.status_code == 404:
return
# Some other error occurred, so re-raise the exception
else:
raise e
batch_size = 1000
for i in range(0, len(ids), batch_size):
batch = ids[i : i + batch_size]
try:
filter = models.Filter(
must=[
models.FieldCondition(
key="metadata.doc_id",
match=models.MatchAny(any=batch),
),
],
)
self._client.delete(
collection_name=self._collection_name,
points_selector=FilterSelector(filter=filter),
)
except UnexpectedResponse as e:
# Collection does not exist, so return
if e.status_code != 404:
raise e
def text_exists(self, id: str) -> bool:
all_collection_name = []
@@ -416,13 +420,16 @@ class TidbOnQdrantVector(BaseVector):
class TidbOnQdrantVectorFactory(AbstractVectorFactory):
def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> TidbOnQdrantVector:
logger.info("init_vector: tenant_id=%s, dataset_id=%s", dataset.tenant_id, dataset.id)
stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id)
tidb_auth_binding = db.session.scalars(stmt).one_or_none()
if not tidb_auth_binding:
logger.info("No existing TidbAuthBinding for tenant %s, acquiring lock", dataset.tenant_id)
with redis_client.lock("create_tidb_serverless_cluster_lock", timeout=900):
stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id)
tidb_auth_binding = db.session.scalars(stmt).one_or_none()
if tidb_auth_binding:
logger.info("Found binding after lock: cluster_id=%s", tidb_auth_binding.cluster_id)
TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}"
else:
@@ -433,11 +440,18 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
.one_or_none()
)
if idle_tidb_auth_binding:
logger.info(
"Assigning idle cluster %s to tenant %s",
idle_tidb_auth_binding.cluster_id,
dataset.tenant_id,
)
idle_tidb_auth_binding.active = True
idle_tidb_auth_binding.tenant_id = dataset.tenant_id
db.session.commit()
tidb_auth_binding = idle_tidb_auth_binding
TIDB_ON_QDRANT_API_KEY = f"{idle_tidb_auth_binding.account}:{idle_tidb_auth_binding.password}"
else:
logger.info("No idle clusters available, creating new cluster for tenant %s", dataset.tenant_id)
new_cluster = TidbService.create_tidb_serverless_cluster(
dify_config.TIDB_PROJECT_ID or "",
dify_config.TIDB_API_URL or "",
@@ -446,21 +460,39 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
dify_config.TIDB_PRIVATE_KEY or "",
dify_config.TIDB_REGION or "",
)
logger.info(
"New cluster created: cluster_id=%s, qdrant_endpoint=%s",
new_cluster["cluster_id"],
new_cluster.get("qdrant_endpoint"),
)
new_tidb_auth_binding = TidbAuthBinding(
cluster_id=new_cluster["cluster_id"],
cluster_name=new_cluster["cluster_name"],
account=new_cluster["account"],
password=new_cluster["password"],
qdrant_endpoint=new_cluster.get("qdrant_endpoint"),
tenant_id=dataset.tenant_id,
active=True,
status=TidbAuthBindingStatus.ACTIVE,
)
db.session.add(new_tidb_auth_binding)
db.session.commit()
tidb_auth_binding = new_tidb_auth_binding
TIDB_ON_QDRANT_API_KEY = f"{new_tidb_auth_binding.account}:{new_tidb_auth_binding.password}"
else:
logger.info("Existing binding found: cluster_id=%s", tidb_auth_binding.cluster_id)
TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}"
qdrant_url = (
(tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or ""
)
logger.info(
"Using qdrant endpoint: %s (from_binding=%s, fallback_global=%s)",
qdrant_url,
tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None,
dify_config.TIDB_ON_QDRANT_URL,
)
if dataset.index_struct_dict:
class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"]
collection_name = class_prefix
@@ -475,7 +507,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
collection_name=collection_name,
group_id=dataset.id,
config=TidbOnQdrantConfig(
endpoint=dify_config.TIDB_ON_QDRANT_URL or "",
endpoint=qdrant_url,
api_key=TIDB_ON_QDRANT_API_KEY,
root_path=str(config.root_path),
timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT,
@@ -1,3 +1,4 @@
import logging
import time
import uuid
from collections.abc import Sequence
@@ -11,8 +12,50 @@ from extensions.ext_redis import redis_client
from models.dataset import TidbAuthBinding
from models.enums import TidbAuthBindingStatus
logger = logging.getLogger(__name__)
class TidbService:
@staticmethod
def extract_qdrant_endpoint(cluster_response: dict) -> str | None:
"""Extract the qdrant endpoint URL from a Get Cluster API response.
Reads ``endpoints.public.host`` (e.g. ``gateway01.xx.tidbcloud.com``),
prepends ``qdrant-`` and wraps it as an ``https://`` URL.
"""
endpoints = cluster_response.get("endpoints") or {}
public = endpoints.get("public") or {}
host = public.get("host")
if host:
return f"https://qdrant-{host}"
return None
@staticmethod
def fetch_qdrant_endpoint(api_url: str, public_key: str, private_key: str, cluster_id: str) -> str | None:
"""Call Get Cluster API and extract the qdrant endpoint.
Use ``extract_qdrant_endpoint`` instead when you already have
the cluster response to avoid a redundant API call.
"""
try:
logger.info("Fetching qdrant endpoint for cluster %s", cluster_id)
cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id)
if not cluster_response:
logger.warning("Empty response from Get Cluster API for cluster %s", cluster_id)
return None
qdrant_url = TidbService.extract_qdrant_endpoint(cluster_response)
if qdrant_url:
logger.info("Resolved qdrant endpoint for cluster %s: %s", cluster_id, qdrant_url)
return qdrant_url
logger.warning(
"No endpoints.public.host found for cluster %s, response keys: %s",
cluster_id,
list(cluster_response.keys()),
)
except Exception:
logger.exception("Failed to fetch qdrant endpoint for cluster %s", cluster_id)
return None
@staticmethod
def create_tidb_serverless_cluster(
project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str
@@ -50,26 +93,45 @@ class TidbService:
"rootPassword": password,
}
logger.info("Creating TiDB serverless cluster: display_name=%s, region=%s", display_name, region)
response = httpx.post(f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key))
if response.status_code == 200:
response_data = response.json()
cluster_id = response_data["clusterId"]
logger.info("Cluster created, cluster_id=%s, waiting for ACTIVE state", cluster_id)
retry_count = 0
max_retries = 30
while retry_count < max_retries:
cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id)
if cluster_response["state"] == "ACTIVE":
user_prefix = cluster_response["userPrefix"]
qdrant_endpoint = TidbService.extract_qdrant_endpoint(cluster_response)
logger.info(
"Cluster %s is ACTIVE, user_prefix=%s, qdrant_endpoint=%s",
cluster_id,
user_prefix,
qdrant_endpoint,
)
return {
"cluster_id": cluster_id,
"cluster_name": display_name,
"account": f"{user_prefix}.root",
"password": password,
"qdrant_endpoint": qdrant_endpoint,
}
time.sleep(30) # wait 30 seconds before retrying
logger.info(
"Cluster %s state=%s, retry %d/%d",
cluster_id,
cluster_response["state"],
retry_count + 1,
max_retries,
)
time.sleep(30)
retry_count += 1
logger.error("Cluster %s did not become ACTIVE after %d retries", cluster_id, max_retries)
else:
logger.error("Failed to create cluster: status=%d, body=%s", response.status_code, response.text)
response.raise_for_status()
@staticmethod
@@ -171,8 +233,20 @@ class TidbService:
userPrefix = item["userPrefix"]
if state == "ACTIVE" and len(userPrefix) > 0:
cluster_info = tidb_serverless_list_map[item["clusterId"]]
cluster_info.status = TidbAuthBindingStatus.ACTIVE
cluster_info.account = f"{userPrefix}.root"
if not cluster_info.qdrant_endpoint:
cluster_info.qdrant_endpoint = TidbService.extract_qdrant_endpoint(
item
) or TidbService.fetch_qdrant_endpoint(
api_url, public_key, private_key, item["clusterId"]
)
if cluster_info.qdrant_endpoint:
cluster_info.status = TidbAuthBindingStatus.ACTIVE
else:
logger.warning(
"Cluster %s is ACTIVE but qdrant endpoint is not ready; will retry later",
item["clusterId"],
)
db.session.add(cluster_info)
db.session.commit()
else:
@@ -230,19 +304,29 @@ class TidbService:
if response.status_code == 200:
response_data = response.json()
cluster_infos = []
logger.info("Batch created %d clusters", len(response_data.get("clusters", [])))
for item in response_data["clusters"]:
cache_key = f"tidb_serverless_cluster_password:{item['displayName']}"
cached_password = redis_client.get(cache_key)
if not cached_password:
logger.warning("No cached password for cluster %s, skipping", item["displayName"])
continue
qdrant_endpoint = TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, item["clusterId"])
logger.info(
"Batch cluster %s: qdrant_endpoint=%s",
item["clusterId"],
qdrant_endpoint,
)
cluster_info = {
"cluster_id": item["clusterId"],
"cluster_name": item["displayName"],
"account": "root",
"password": cached_password.decode("utf-8"),
"qdrant_endpoint": qdrant_endpoint,
}
cluster_infos.append(cluster_info)
return cluster_infos
else:
logger.error("Batch create failed: status=%d, body=%s", response.status_code, response.text)
response.raise_for_status()
return []
-188
View File
@@ -1,56 +1,17 @@
import logging
from dataclasses import dataclass
from enum import StrEnum, auto
logger = logging.getLogger(__name__)
@dataclass
class QuotaCharge:
"""
Result of a quota consumption operation.
Attributes:
success: Whether the quota charge succeeded
charge_id: UUID for refund, or None if failed/disabled
"""
success: bool
charge_id: str | None
_quota_type: "QuotaType"
def refund(self) -> None:
"""
Refund this quota charge.
Safe to call even if charge failed or was disabled.
This method guarantees no exceptions will be raised.
"""
if self.charge_id:
self._quota_type.refund(self.charge_id)
logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id)
class QuotaType(StrEnum):
"""
Supported quota types for tenant feature usage.
Add additional types here whenever new billable features become available.
"""
# Trigger execution quota
TRIGGER = auto()
# Workflow execution quota
WORKFLOW = auto()
UNLIMITED = auto()
@property
def billing_key(self) -> str:
"""
Get the billing key for the feature.
"""
match self:
case QuotaType.TRIGGER:
return "trigger_event"
@@ -58,152 +19,3 @@ class QuotaType(StrEnum):
return "api_rate_limit"
case _:
raise ValueError(f"Invalid quota type: {self}")
def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Consume quota for the feature.
Args:
tenant_id: The tenant identifier
amount: Amount to consume (default: 1)
Returns:
QuotaCharge with success status and charge_id for refund
Raises:
QuotaExceededError: When quota is insufficient
"""
from configs import dify_config
from services.billing_service import BillingService
from services.errors.app import QuotaExceededError
if not dify_config.BILLING_ENABLED:
logger.debug("Billing disabled, allowing request for %s", tenant_id)
return QuotaCharge(success=True, charge_id=None, _quota_type=self)
logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id)
if amount <= 0:
raise ValueError("Amount to consume must be greater than 0")
try:
response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount)
if response.get("result") != "success":
logger.warning(
"Failed to consume quota for %s, feature %s details: %s",
tenant_id,
self.value,
response.get("detail"),
)
raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount)
charge_id = response.get("history_id")
logger.debug(
"Successfully consumed %d %s quota for tenant %s, charge_id: %s",
amount,
self.value,
tenant_id,
charge_id,
)
return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self)
except QuotaExceededError:
raise
except Exception:
# fail-safe: allow request on billing errors
logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value)
return unlimited()
def check(self, tenant_id: str, amount: int = 1) -> bool:
"""
Check if tenant has sufficient quota without consuming.
Args:
tenant_id: The tenant identifier
amount: Amount to check (default: 1)
Returns:
True if quota is sufficient, False otherwise
"""
from configs import dify_config
if not dify_config.BILLING_ENABLED:
return True
if amount <= 0:
raise ValueError("Amount to check must be greater than 0")
try:
remaining = self.get_remaining(tenant_id)
return remaining >= amount if remaining != -1 else True
except Exception:
logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value)
# fail-safe: allow request on billing errors
return True
def refund(self, charge_id: str) -> None:
"""
Refund quota using charge_id from consume().
This method guarantees no exceptions will be raised.
All errors are logged but silently handled.
Args:
charge_id: The UUID returned from consume()
"""
try:
from configs import dify_config
from services.billing_service import BillingService
if not dify_config.BILLING_ENABLED:
return
if not charge_id:
logger.warning("Cannot refund: charge_id is empty")
return
logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id)
response = BillingService.refund_tenant_feature_plan_usage(charge_id)
if response.get("result") == "success":
logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id)
else:
logger.warning("Refund failed for charge_id: %s", charge_id)
except Exception:
# Catch ALL exceptions - refund must never fail
logger.exception("Failed to refund quota for charge_id: %s", charge_id)
# Don't raise - refund is best-effort and must be silent
def get_remaining(self, tenant_id: str) -> int:
"""
Get remaining quota for the tenant.
Args:
tenant_id: The tenant identifier
Returns:
Remaining quota amount
"""
from services.billing_service import BillingService
try:
usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key)
# Assuming the API returns a dict with 'remaining' or 'limit' and 'used'
if isinstance(usage_info, dict):
return usage_info.get("remaining", 0)
# If it returns a simple number, treat it as remaining
return int(usage_info) if usage_info else 0
except Exception:
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value)
return -1
def unlimited() -> QuotaCharge:
"""
Return a quota charge for unlimited quota.
This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type.
"""
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
@@ -0,0 +1,26 @@
"""add qdrant_endpoint to tidb_auth_bindings
Revision ID: 8574b23a38fd
Revises: 6b5f9f8b1a2c
Create Date: 2026-04-14 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "8574b23a38fd"
down_revision = "6b5f9f8b1a2c"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op:
batch_op.add_column(sa.Column("qdrant_endpoint", sa.String(length=512), nullable=True))
def downgrade():
with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op:
batch_op.drop_column("qdrant_endpoint")
+1
View File
@@ -1250,6 +1250,7 @@ class TidbAuthBinding(TypeBase):
)
account: Mapped[str] = mapped_column(String(255), nullable=False)
password: Mapped[str] = mapped_column(String(255), nullable=False)
qdrant_endpoint: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
+1
View File
@@ -113,6 +113,7 @@ class DataSourceType(StrEnum):
WEBSITE_CRAWL = "website_crawl"
LOCAL_FILE = "local_file"
ONLINE_DOCUMENT = "online_document"
ONLINE_DRIVE = "online_drive"
class ProcessRuleMode(StrEnum):
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
requires-python = ">=3.11,<3.13"
dependencies = [
@@ -57,6 +57,7 @@ def create_clusters(batch_size):
cluster_name=new_cluster["cluster_name"],
account=new_cluster["account"],
password=new_cluster["password"],
qdrant_endpoint=new_cluster.get("qdrant_endpoint"),
active=False,
status=TidbAuthBindingStatus.CREATING,
)
+187 -145
View File
@@ -4,7 +4,7 @@ import logging
import threading
import uuid
from collections.abc import Callable, Generator, Mapping
from typing import TYPE_CHECKING, Any, Union
from typing import TYPE_CHECKING, Any
from configs import dify_config
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
@@ -18,12 +18,13 @@ 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 enums.quota_type import QuotaType, unlimited
from enums.quota_type import 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.llm import InvokeRateLimitError
from services.quota_service import QuotaService, unlimited
from services.workflow_service import WorkflowService
from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task
@@ -88,7 +89,7 @@ class AppGenerateService:
def generate(
cls,
app_model: App,
user: Union[Account, EndUser],
user: Account | EndUser,
args: Mapping[str, Any],
invoke_from: InvokeFrom,
streaming: bool = True,
@@ -106,7 +107,7 @@ class AppGenerateService:
quota_charge = unlimited()
if dify_config.BILLING_ENABLED:
try:
quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id)
except QuotaExceededError:
raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}")
@@ -116,139 +117,150 @@ class AppGenerateService:
request_id = RateLimit.gen_request_key()
try:
request_id = rate_limit.enter(request_id)
if app_model.mode == AppMode.COMPLETION:
return rate_limit.generate(
CompletionAppGenerator.convert_to_event_stream(
CompletionAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
elif app_model.mode == AppMode.AGENT_CHAT or app_model.is_agent:
return rate_limit.generate(
AgentChatAppGenerator.convert_to_event_stream(
AgentChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
elif app_model.mode == AppMode.CHAT:
return rate_limit.generate(
ChatAppGenerator.convert_to_event_stream(
ChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
elif app_model.mode == AppMode.ADVANCED_CHAT:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
generator = AdvancedChatAppGenerator()
quota_charge.commit()
effective_mode = (
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
)
match effective_mode:
case AppMode.COMPLETION:
return rate_limit.generate(
generator.convert_to_event_stream(
generator.retrieve_events(
AppMode.ADVANCED_CHAT,
payload.workflow_run_id,
on_subscribe=on_subscribe,
CompletionAppGenerator.convert_to_event_stream(
CompletionAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
else:
# Blocking mode: run synchronously and return JSON instead of SSE
# Keep behaviour consistent with WORKFLOW blocking branch.
advanced_generator = AdvancedChatAppGenerator()
case AppMode.AGENT_CHAT:
return rate_limit.generate(
advanced_generator.convert_to_event_stream(
advanced_generator.generate(
AgentChatAppGenerator.convert_to_event_stream(
AgentChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id,
)
case AppMode.CHAT:
return rate_limit.generate(
ChatAppGenerator.convert_to_event_stream(
ChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
),
),
request_id=request_id,
)
case AppMode.ADVANCED_CHAT:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
workflow_run_id=str(uuid.uuid4()),
streaming=False,
)
),
request_id=request_id,
)
elif app_model.mode == AppMode.WORKFLOW:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
root_node_id=root_node_id,
workflow_run_id=str(uuid.uuid4()),
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
generator = AdvancedChatAppGenerator()
return rate_limit.generate(
generator.convert_to_event_stream(
generator.retrieve_events(
AppMode.ADVANCED_CHAT,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id=request_id,
)
payload_json = payload.model_dump_json()
else:
# Blocking mode: run synchronously and return JSON instead of SSE
# Keep behaviour consistent with WORKFLOW blocking branch.
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
advanced_generator = AdvancedChatAppGenerator()
return rate_limit.generate(
advanced_generator.convert_to_event_stream(
advanced_generator.generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
workflow_run_id=str(uuid.uuid4()),
streaming=False,
pause_state_config=pause_config,
)
),
request_id=request_id,
)
case AppMode.WORKFLOW:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
if streaming:
with rate_limit_context(rate_limit, request_id):
payload = AppExecutionParams.new(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=True,
call_depth=0,
root_node_id=root_node_id,
workflow_run_id=str(uuid.uuid4()),
)
payload_json = payload.model_dump_json()
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
def on_subscribe():
workflow_based_app_execution_task.delay(payload_json)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
MessageBasedAppGenerator.retrieve_events(
AppMode.WORKFLOW,
payload.workflow_run_id,
on_subscribe=on_subscribe,
),
),
request_id,
)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
MessageBasedAppGenerator.retrieve_events(
AppMode.WORKFLOW,
payload.workflow_run_id,
on_subscribe=on_subscribe,
WorkflowAppGenerator().generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=False,
root_node_id=root_node_id,
call_depth=0,
pause_state_config=pause_config,
),
),
request_id,
)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,
)
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().generate(
app_model=app_model,
workflow=workflow,
user=user,
args=args,
invoke_from=invoke_from,
streaming=False,
root_node_id=root_node_id,
call_depth=0,
pause_state_config=pause_config,
),
),
request_id,
)
else:
raise ValueError(f"Invalid app mode {app_model.mode}")
case _:
raise ValueError(f"Invalid app mode {app_model.mode}")
except Exception:
quota_charge.refund()
rate_limit.exit(request_id)
@@ -280,53 +292,83 @@ class AppGenerateService:
@classmethod
def generate_single_iteration(cls, app_model: App, user: Account, node_id: str, args: Any, streaming: bool = True):
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
AdvancedChatAppGenerator().single_iteration_generate(
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
match app_model.mode:
case AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT:
raise ValueError(f"Invalid app mode {app_model.mode}")
case AppMode.ADVANCED_CHAT:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
AdvancedChatAppGenerator().single_iteration_generate(
app_model=app_model,
workflow=workflow,
node_id=node_id,
user=user,
args=args,
streaming=streaming,
)
)
)
elif app_model.mode == AppMode.WORKFLOW:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_iteration_generate(
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
case AppMode.WORKFLOW:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_iteration_generate(
app_model=app_model,
workflow=workflow,
node_id=node_id,
user=user,
args=args,
streaming=streaming,
)
)
)
else:
raise ValueError(f"Invalid app mode {app_model.mode}")
case AppMode.CHANNEL | AppMode.RAG_PIPELINE:
raise ValueError(f"Invalid app mode {app_model.mode}")
case _:
raise ValueError(f"Invalid app mode {app_model.mode}")
@classmethod
def generate_single_loop(
cls, app_model: App, user: Account, node_id: str, args: LoopNodeRunPayload, streaming: bool = True
):
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
AdvancedChatAppGenerator().single_loop_generate(
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
match app_model.mode:
case AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT:
raise ValueError(f"Invalid app mode {app_model.mode}")
case AppMode.ADVANCED_CHAT:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
AdvancedChatAppGenerator().single_loop_generate(
app_model=app_model,
workflow=workflow,
node_id=node_id,
user=user,
args=args,
streaming=streaming,
)
)
)
elif app_model.mode == AppMode.WORKFLOW:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_loop_generate(
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
case AppMode.WORKFLOW:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().single_loop_generate(
app_model=app_model,
workflow=workflow,
node_id=node_id,
user=user,
args=args,
streaming=streaming,
)
)
)
else:
raise ValueError(f"Invalid app mode {app_model.mode}")
case AppMode.CHANNEL | AppMode.RAG_PIPELINE:
raise ValueError(f"Invalid app mode {app_model.mode}")
case _:
raise ValueError(f"Invalid app mode {app_model.mode}")
@classmethod
def generate_more_like_this(
cls,
app_model: App,
user: Union[Account, EndUser],
user: Account | EndUser,
message_id: str,
invoke_from: InvokeFrom,
streaming: bool = True,
) -> Union[Mapping, Generator]:
) -> Mapping | Generator:
"""
Generate more like this
:param app_model: app model
+31 -13
View File
@@ -22,6 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
from models.workflow import Workflow
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
from services.quota_service import QuotaService, unlimited
from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData
from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority
from services.workflow_service import WorkflowService
@@ -88,7 +89,10 @@ class AsyncWorkflowService:
raise WorkflowNotFoundError(f"App not found: {trigger_data.app_id}")
# 2. Get workflow
workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id)
workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id, session=session)
# commit read only session before starting the billig rpc call
session.commit()
# 3. Get dispatcher based on tenant subscription
dispatcher = dispatcher_manager.get_dispatcher(trigger_data.tenant_id)
@@ -131,9 +135,10 @@ class AsyncWorkflowService:
trigger_log = trigger_log_repo.create(trigger_log)
session.commit()
# 7. Check and consume quota
# 7. Reserve quota (commit after successful dispatch)
quota_charge = unlimited()
try:
QuotaType.WORKFLOW.consume(trigger_data.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id)
except QuotaExceededError as e:
# Update trigger log status
trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED
@@ -153,13 +158,18 @@ class AsyncWorkflowService:
# 9. Dispatch to appropriate queue
task_data_dict = task_data.model_dump(mode="json")
task: AsyncResult[Any] | None = None
if queue_name == QueuePriority.PROFESSIONAL:
task = execute_workflow_professional.delay(task_data_dict)
elif queue_name == QueuePriority.TEAM:
task = execute_workflow_team.delay(task_data_dict)
else: # SANDBOX
task = execute_workflow_sandbox.delay(task_data_dict)
try:
task: AsyncResult[Any] | None = None
if queue_name == QueuePriority.PROFESSIONAL:
task = execute_workflow_professional.delay(task_data_dict)
elif queue_name == QueuePriority.TEAM:
task = execute_workflow_team.delay(task_data_dict)
else: # SANDBOX
task = execute_workflow_sandbox.delay(task_data_dict)
quota_charge.commit()
except Exception:
quota_charge.refund()
raise
# 10. Update trigger log with task info
trigger_log.status = WorkflowTriggerStatus.QUEUED
@@ -295,13 +305,21 @@ class AsyncWorkflowService:
return [log.to_dict() for log in logs]
@staticmethod
def _get_workflow(workflow_service: WorkflowService, app_model: App, workflow_id: str | None = None) -> Workflow:
def _get_workflow(
workflow_service: WorkflowService,
app_model: App,
workflow_id: str | None = None,
session: Session | None = None,
) -> Workflow:
"""
Get workflow for the app
Args:
app_model: App model instance
workflow_id: Optional specific workflow ID
session: Reuse this SQLAlchemy session for the lookup when provided,
so the caller's explicit session bears the connection cost
instead of Flask's request-scoped ``db.session``.
Returns:
Workflow instance
@@ -311,12 +329,12 @@ class AsyncWorkflowService:
"""
if workflow_id:
# Get specific published workflow
workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id)
workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id, session=session)
if not workflow:
raise WorkflowNotFoundError(f"Published workflow not found: {workflow_id}")
else:
# Get default published workflow
workflow = workflow_service.get_published_workflow(app_model)
workflow = workflow_service.get_published_workflow(app_model, session=session)
if not workflow:
raise WorkflowNotFoundError(f"No published workflow found for app: {app_model.id}")
+198 -6
View File
@@ -2,12 +2,11 @@ import json
import logging
import os
from collections.abc import Sequence
from typing import Literal
from typing import Literal, NotRequired, TypedDict
import httpx
from pydantic import TypeAdapter
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
from typing_extensions import TypedDict
from werkzeug.exceptions import InternalServerError
from enums.cloud_plan import CloudPlan
@@ -26,6 +25,147 @@ class SubscriptionPlan(TypedDict):
expiration_date: int
class QuotaReserveResult(TypedDict):
reservation_id: str
available: int
reserved: int
class QuotaCommitResult(TypedDict):
available: int
reserved: int
refunded: int
class QuotaReleaseResult(TypedDict):
available: int
reserved: int
released: int
_quota_reserve_adapter = TypeAdapter(QuotaReserveResult)
_quota_commit_adapter = TypeAdapter(QuotaCommitResult)
_quota_release_adapter = TypeAdapter(QuotaReleaseResult)
class _TenantFeatureQuota(TypedDict):
usage: int
limit: int
reset_date: NotRequired[int]
class TenantFeatureQuotaInfo(TypedDict):
"""Response of /quota/info.
NOTE (hj24):
- Same convention as BillingInfo: billing may return int fields as str,
always keep non-strict mode to auto-coerce.
"""
trigger_event: _TenantFeatureQuota
api_rate_limit: _TenantFeatureQuota
_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo)
class _BillingQuota(TypedDict):
size: int
limit: int
class _VectorSpaceQuota(TypedDict):
size: float
limit: int
class _KnowledgeRateLimit(TypedDict):
# NOTE (hj24):
# 1. Return for sandbox users but is null for other plans, it's defined but never used.
# 2. Keep it for compatibility for now, can be deprecated in future versions.
size: NotRequired[int]
# NOTE END
limit: int
class _BillingSubscription(TypedDict):
plan: str
interval: str
education: bool
class BillingInfo(TypedDict):
"""Response of /subscription/info.
NOTE (hj24):
- Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python()
- To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter:
1. validate_python in non-strict mode will coerce it to the expected type
2. In strict mode, it will raise ValidationError
3. To preserve compatibility, always keep non-strict mode here and avoid strict mode
"""
enabled: bool
subscription: _BillingSubscription
members: _BillingQuota
apps: _BillingQuota
vector_space: _VectorSpaceQuota
knowledge_rate_limit: _KnowledgeRateLimit
documents_upload_quota: _BillingQuota
annotation_quota_limit: _BillingQuota
docs_processing: str
can_replace_logo: bool
model_load_balancing_enabled: bool
knowledge_pipeline_publish_enabled: bool
next_credit_reset_date: NotRequired[int]
_billing_info_adapter = TypeAdapter(BillingInfo)
class KnowledgeRateLimitDict(TypedDict):
limit: int
subscription_plan: str
class TenantFeaturePlanUsageDict(TypedDict):
result: str
history_id: str
class LangContentDict(TypedDict):
lang: str
title: str
subtitle: str
body: str
title_pic_url: str
class NotificationDict(TypedDict):
notification_id: str
contents: dict[str, LangContentDict]
frequency: Literal["once", "every_page_load"]
class AccountNotificationDict(TypedDict, total=False):
should_show: bool
notification: NotificationDict
shouldShow: bool
notifications: list[dict]
class UpsertNotificationDict(TypedDict):
notification_id: str
class BatchAddNotificationAccountsDict(TypedDict):
count: int
class DismissNotificationDict(TypedDict):
success: bool
class BillingService:
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY")
@@ -38,21 +178,73 @@ class BillingService:
_PLAN_CACHE_TTL = 600
@classmethod
def get_info(cls, tenant_id: str):
def get_info(cls, tenant_id: str) -> BillingInfo:
params = {"tenant_id": tenant_id}
billing_info = cls._send_request("GET", "/subscription/info", params=params)
return billing_info
return _billing_info_adapter.validate_python(billing_info)
@classmethod
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
"""Deprecated: Use get_quota_info instead."""
params = {"tenant_id": tenant_id}
usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params)
return usage_info
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str):
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
params = {"tenant_id": tenant_id}
return _tenant_feature_quota_info_adapter.validate_python(
cls._send_request("GET", "/quota/info", params=params)
)
@classmethod
def quota_reserve(
cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None
) -> QuotaReserveResult:
"""Reserve quota before task execution."""
payload: dict = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"request_id": request_id,
"amount": amount,
}
if meta:
payload["meta"] = meta
return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload))
@classmethod
def quota_commit(
cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None
) -> QuotaCommitResult:
"""Commit a reservation with actual consumption."""
payload: dict = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
"actual_amount": actual_amount,
}
if meta:
payload["meta"] = meta
return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload))
@classmethod
def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult:
"""Release a reservation (cancel, return frozen quota)."""
return _quota_release_adapter.validate_python(
cls._send_request(
"POST",
"/quota/release",
json={
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
},
)
)
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict:
params = {"tenant_id": tenant_id}
knowledge_rate_limit = cls._send_request("GET", "/subscription/knowledge-rate-limit", params=params)
+10 -9
View File
@@ -1,7 +1,7 @@
import logging
from sqlalchemy import update
from sqlalchemy.orm import Session
from sqlalchemy import select, update
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.errors.error import QuotaExceededError
@@ -29,14 +29,15 @@ class CreditPoolService:
@classmethod
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
"""get tenant credit pool"""
return (
db.session.query(TenantCreditPool)
.filter_by(
tenant_id=tenant_id,
pool_type=pool_type,
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
return session.scalar(
select(TenantCreditPool)
.where(
TenantCreditPool.tenant_id == tenant_id,
TenantCreditPool.pool_type == pool_type,
)
.limit(1)
)
.first()
)
@classmethod
def check_credits_available(
+9 -2
View File
@@ -281,7 +281,7 @@ class FeatureService:
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
billing_info = BillingService.get_info(tenant_id)
features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id)
features_usage_info = BillingService.get_quota_info(tenant_id)
features.billing.enabled = billing_info["enabled"]
features.billing.subscription.plan = billing_info["subscription"]["plan"]
@@ -312,7 +312,10 @@ class FeatureService:
features.apps.limit = billing_info["apps"]["limit"]
if "vector_space" in billing_info:
features.vector_space.size = billing_info["vector_space"]["size"]
# NOTE (hj24): billing API returns vector_space.size as float (e.g. 0.0)
# but LimitationModel.size is int; truncate here for compatibility
features.vector_space.size = int(billing_info["vector_space"]["size"])
# NOTE END
features.vector_space.limit = billing_info["vector_space"]["limit"]
if "documents_upload_quota" in billing_info:
@@ -333,7 +336,11 @@ class FeatureService:
features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"]
if "knowledge_rate_limit" in billing_info:
# NOTE (hj24):
# 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used.
# 2. So be careful if later we decide to use [size], we cannot assume it is always present.
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
# NOTE END
if "knowledge_pipeline_publish_enabled" in billing_info:
features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"]
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from configs import dify_config
if TYPE_CHECKING:
from enums.quota_type import QuotaType
logger = logging.getLogger(__name__)
@dataclass
class QuotaCharge:
"""
Result of a quota reservation (Reserve phase).
Lifecycle:
charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id)
try:
do_work()
charge.commit() # Confirm consumption
except:
charge.refund() # Release frozen quota
If neither commit() nor refund() is called, the billing system's
cleanup CronJob will auto-release the reservation within ~75 seconds.
"""
success: bool
charge_id: str | None # reservation_id
_quota_type: QuotaType
_tenant_id: str | None = None
_feature_key: str | None = None
_amount: int = 0
_committed: bool = field(default=False, repr=False)
def commit(self, actual_amount: int | None = None) -> None:
"""
Confirm the consumption with actual amount.
Args:
actual_amount: Actual amount consumed. Defaults to the reserved amount.
If less than reserved, the difference is refunded automatically.
"""
if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key:
return
try:
from services.billing_service import BillingService
amount = actual_amount if actual_amount is not None else self._amount
BillingService.quota_commit(
tenant_id=self._tenant_id,
feature_key=self._feature_key,
reservation_id=self.charge_id,
actual_amount=amount,
)
self._committed = True
logger.debug(
"Committed %s quota for tenant %s, reservation_id: %s, amount: %d",
self._quota_type,
self._tenant_id,
self.charge_id,
amount,
)
except Exception:
logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id)
def refund(self) -> None:
"""
Release the reserved quota (cancel the charge).
Safe to call even if:
- charge failed or was disabled (charge_id is None)
- already committed (Release after Commit is a no-op)
- already refunded (idempotent)
This method guarantees no exceptions will be raised.
"""
if not self.charge_id or not self._tenant_id or not self._feature_key:
return
QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key)
def unlimited() -> QuotaCharge:
from enums.quota_type import QuotaType
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
class QuotaService:
"""Orchestrates quota reserve / commit / release lifecycle via BillingService."""
@staticmethod
def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Reserve + immediate Commit (one-shot mode).
The returned QuotaCharge supports .refund() which calls Release.
For two-phase usage (e.g. streaming), use reserve() directly.
"""
charge = QuotaService.reserve(quota_type, tenant_id, amount)
if charge.success and charge.charge_id:
charge.commit()
return charge
@staticmethod
def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Reserve quota before task execution (Reserve phase only).
The caller MUST call charge.commit() after the task succeeds,
or charge.refund() if the task fails.
Raises:
QuotaExceededError: When quota is insufficient
"""
from services.billing_service import BillingService
from services.errors.app import QuotaExceededError
if not dify_config.BILLING_ENABLED:
logger.debug("Billing disabled, allowing request for %s", tenant_id)
return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type)
logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id)
if amount <= 0:
raise ValueError("Amount to reserve must be greater than 0")
request_id = str(uuid.uuid4())
feature_key = quota_type.billing_key
try:
reserve_resp = BillingService.quota_reserve(
tenant_id=tenant_id,
feature_key=feature_key,
request_id=request_id,
amount=amount,
)
reservation_id = reserve_resp.get("reservation_id")
if not reservation_id:
logger.warning(
"Reserve returned no reservation_id for %s, feature %s, response: %s",
tenant_id,
quota_type.value,
reserve_resp,
)
raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount)
logger.debug(
"Reserved %d %s quota for tenant %s, reservation_id: %s",
amount,
quota_type.value,
tenant_id,
reservation_id,
)
return QuotaCharge(
success=True,
charge_id=reservation_id,
_quota_type=quota_type,
_tenant_id=tenant_id,
_feature_key=feature_key,
_amount=amount,
)
except QuotaExceededError:
raise
except ValueError:
raise
except Exception:
logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value)
return unlimited()
@staticmethod
def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool:
if not dify_config.BILLING_ENABLED:
return True
if amount <= 0:
raise ValueError("Amount to check must be greater than 0")
try:
remaining = QuotaService.get_remaining(quota_type, tenant_id)
return remaining >= amount if remaining != -1 else True
except Exception:
logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value)
return True
@staticmethod
def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None:
"""Release a reservation. Guarantees no exceptions."""
try:
from services.billing_service import BillingService
if not dify_config.BILLING_ENABLED:
return
if not reservation_id:
return
logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id)
BillingService.quota_release(
tenant_id=tenant_id,
feature_key=feature_key,
reservation_id=reservation_id,
)
except Exception:
logger.exception("Failed to release quota, reservation_id: %s", reservation_id)
@staticmethod
def get_remaining(quota_type: QuotaType, tenant_id: str) -> int:
from services.billing_service import BillingService
try:
usage_info = BillingService.get_quota_info(tenant_id)
if isinstance(usage_info, dict):
feature_info = usage_info.get(quota_type.billing_key, {})
if isinstance(feature_info, dict):
limit = feature_info.get("limit", 0)
usage = feature_info.get("usage", 0)
if limit == -1:
return -1
return max(0, limit - usage)
return 0
except Exception:
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value)
return -1
+38 -35
View File
@@ -37,6 +37,7 @@ from models.workflow import Workflow
from services.async_workflow_service import AsyncWorkflowService
from services.end_user_service import EndUserService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService
from services.trigger.app_trigger_service import AppTriggerService
from services.workflow.entities import WebhookTriggerData
@@ -758,45 +759,47 @@ class WebhookService:
Exception: If workflow execution fails
"""
try:
with Session(db.engine) as session:
# Prepare inputs for the webhook node
# The webhook node expects webhook_data in the inputs
workflow_inputs = cls.build_workflow_inputs(webhook_data)
workflow_inputs = cls.build_workflow_inputs(webhook_data)
# Create trigger data
trigger_data = WebhookTriggerData(
app_id=webhook_trigger.app_id,
workflow_id=workflow.id,
root_node_id=webhook_trigger.node_id, # Start from the webhook node
inputs=workflow_inputs,
tenant_id=webhook_trigger.tenant_id,
trigger_data = WebhookTriggerData(
app_id=webhook_trigger.app_id,
workflow_id=workflow.id,
root_node_id=webhook_trigger.node_id,
inputs=workflow_inputs,
tenant_id=webhook_trigger.tenant_id,
)
end_user = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.TRIGGER,
tenant_id=webhook_trigger.tenant_id,
app_id=webhook_trigger.app_id,
user_id=None,
)
try:
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
logger.info(
"Tenant %s rate limited, skipping webhook trigger %s",
webhook_trigger.tenant_id,
webhook_trigger.webhook_id,
)
raise
end_user = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.TRIGGER,
tenant_id=webhook_trigger.tenant_id,
app_id=webhook_trigger.app_id,
user_id=None,
)
# consume quota before triggering workflow execution
try:
QuotaType.TRIGGER.consume(webhook_trigger.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
logger.info(
"Tenant %s rate limited, skipping webhook trigger %s",
webhook_trigger.tenant_id,
webhook_trigger.webhook_id,
try:
# NOTE: don not use `with sessionmaker(bind=db.engine, expire_on_commit=False).begin()`
# trigger_workflow_async need to handle multipe session commits internally
with Session(db.engine, expire_on_commit=False) as session:
AsyncWorkflowService.trigger_workflow_async(
session,
end_user,
trigger_data,
)
raise
# Trigger workflow execution asynchronously
AsyncWorkflowService.trigger_workflow_async(
session,
end_user,
trigger_data,
)
quota_charge.commit()
except Exception:
quota_charge.refund()
raise
except Exception:
logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id)
+23 -12
View File
@@ -132,31 +132,38 @@ class WorkflowService:
if workflow_id:
return self.get_published_workflow_by_id(app_model, workflow_id)
# fetch draft workflow by app_model
workflow = (
db.session.query(Workflow)
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id,
Workflow.version == Workflow.VERSION_DRAFT,
)
.first()
.limit(1)
)
# return draft workflow
return workflow
def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Workflow | None:
def get_published_workflow_by_id(
self, app_model: App, workflow_id: str, session: Session | None = None
) -> Workflow | None:
"""
fetch published workflow by workflow_id
When ``session`` is provided, reuse it so callers that already hold a
Session avoid checking out an extra request-scoped ``db.session``
connection. Falls back to ``db.session`` for backward compatibility.
"""
workflow = (
db.session.query(Workflow)
bind = session if session is not None else db.session
workflow = bind.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id,
Workflow.id == workflow_id,
)
.first()
.limit(1)
)
if not workflow:
return None
@@ -167,23 +174,27 @@ class WorkflowService:
)
return workflow
def get_published_workflow(self, app_model: App) -> Workflow | None:
def get_published_workflow(self, app_model: App, session: Session | None = None) -> Workflow | None:
"""
Get published workflow
When ``session`` is provided, reuse it so callers that already hold a
Session avoid checking out an extra request-scoped ``db.session``
connection. Falls back to ``db.session`` for backward compatibility.
"""
if not app_model.workflow_id:
return None
# fetch published workflow by workflow_id
workflow = (
db.session.query(Workflow)
bind = session if session is not None else db.session
workflow = bind.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id,
Workflow.id == app_model.workflow_id,
)
.first()
.limit(1)
)
return workflow
+6 -1
View File
@@ -156,7 +156,12 @@ def _execute_workflow_common(
state_owner_user_id=workflow.created_by,
)
# Execute the workflow with the trigger type
# NOTE (hj24)
# Release the transaction before the blocking generate() call,
# otherwise the connection stays "idle in transaction" for hours.
session.commit()
# NOTE END
generator.generate(
app_model=app_model,
workflow=workflow,
+49 -48
View File
@@ -28,7 +28,7 @@ from core.trigger.provider import PluginTriggerProviderController
from core.trigger.trigger_manager import TriggerManager
from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData
from dify_graph.enums import WorkflowExecutionStatus
from enums.quota_type import QuotaType, unlimited
from enums.quota_type import QuotaType
from models.enums import (
AppTriggerType,
CreatorUserRole,
@@ -42,6 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom,
from services.async_workflow_service import AsyncWorkflowService
from services.end_user_service import EndUserService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService, unlimited
from services.trigger.app_trigger_service import AppTriggerService
from services.trigger.trigger_provider_service import TriggerProviderService
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
@@ -258,59 +259,58 @@ def dispatch_triggered_workflow(
tenant_id=subscription.tenant_id, provider_id=TriggerProviderID(subscription.provider_id)
)
trigger_entity: TriggerProviderEntity = provider_controller.entity
# Ensure expire_on_commit is set to False to remain workflows available
with session_factory.create_session() as session:
workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers)
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
type=InvokeFrom.TRIGGER,
tenant_id=subscription.tenant_id,
app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers],
user_id=user_id,
)
for plugin_trigger in subscribers:
# Get workflow from mapping
workflow: Workflow | None = workflows.get(plugin_trigger.app_id)
if not workflow:
logger.error(
"Workflow not found for app %s",
plugin_trigger.app_id,
)
continue
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
type=InvokeFrom.TRIGGER,
tenant_id=subscription.tenant_id,
app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers],
user_id=user_id,
)
# Find the trigger node in the workflow
event_node = None
for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE):
if node_id == plugin_trigger.node_id:
event_node = node_config
break
if not event_node:
logger.error("Trigger event node not found for app %s", plugin_trigger.app_id)
continue
# invoke trigger
trigger_metadata = PluginTriggerMetadata(
plugin_unique_identifier=provider_controller.plugin_unique_identifier or "",
endpoint_id=subscription.endpoint_id,
provider_id=subscription.provider_id,
event_name=event_name,
icon_filename=trigger_entity.identity.icon or "",
icon_dark_filename=trigger_entity.identity.icon_dark or "",
for plugin_trigger in subscribers:
workflow: Workflow | None = workflows.get(plugin_trigger.app_id)
if not workflow:
logger.error(
"Workflow not found for app %s",
plugin_trigger.app_id,
)
continue
# consume quota before invoking trigger
quota_charge = unlimited()
try:
quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
logger.info(
"Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id
)
return 0
event_node = None
for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE):
if node_id == plugin_trigger.node_id:
event_node = node_config
break
node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node)
invoke_response: TriggerInvokeEventResponse | None = None
if not event_node:
logger.error("Trigger event node not found for app %s", plugin_trigger.app_id)
continue
trigger_metadata = PluginTriggerMetadata(
plugin_unique_identifier=provider_controller.plugin_unique_identifier or "",
endpoint_id=subscription.endpoint_id,
provider_id=subscription.provider_id,
event_name=event_name,
icon_filename=trigger_entity.identity.icon or "",
icon_dark_filename=trigger_entity.identity.icon_dark or "",
)
quota_charge = unlimited()
try:
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
logger.info("Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id)
return dispatched_count
node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node)
invoke_response: TriggerInvokeEventResponse | None = None
with session_factory.create_session() as session:
try:
invoke_response = TriggerManager.invoke_trigger_event(
tenant_id=subscription.tenant_id,
@@ -387,6 +387,7 @@ def dispatch_triggered_workflow(
raise ValueError(f"End user not found for app {plugin_trigger.app_id}")
AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data)
quota_charge.commit()
dispatched_count += 1
logger.info(
"Triggered workflow for app %s with trigger event %s",
@@ -401,7 +402,7 @@ def dispatch_triggered_workflow(
plugin_trigger.app_id,
)
return dispatched_count
return dispatched_count
def dispatch_triggered_workflows(
+19 -16
View File
@@ -8,10 +8,11 @@ from core.workflow.nodes.trigger_schedule.exc import (
ScheduleNotFoundError,
TenantOwnerNotFoundError,
)
from enums.quota_type import QuotaType, unlimited
from enums.quota_type import QuotaType
from models.trigger import WorkflowSchedulePlan
from services.async_workflow_service import AsyncWorkflowService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService, unlimited
from services.trigger.app_trigger_service import AppTriggerService
from services.trigger.schedule_service import ScheduleService
from services.workflow.entities import ScheduleTriggerData
@@ -32,6 +33,7 @@ def run_schedule_trigger(schedule_id: str) -> None:
TenantOwnerNotFoundError: If no owner/admin for tenant
ScheduleExecutionError: If workflow trigger fails
"""
# Ensure expire_on_commit is set to False to remain schedule/tenant_owner available
with session_factory.create_session() as session:
schedule = session.get(WorkflowSchedulePlan, schedule_id)
if not schedule:
@@ -41,16 +43,16 @@ def run_schedule_trigger(schedule_id: str) -> None:
if not tenant_owner:
raise TenantOwnerNotFoundError(f"No owner or admin found for tenant {schedule.tenant_id}")
quota_charge = unlimited()
try:
quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
return
quota_charge = unlimited()
try:
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
return
try:
# Production dispatch: Trigger the workflow normally
try:
with session_factory.create_session() as session:
response = AsyncWorkflowService.trigger_workflow_async(
session=session,
user=tenant_owner,
@@ -61,9 +63,10 @@ def run_schedule_trigger(schedule_id: str) -> None:
tenant_id=schedule.tenant_id,
),
)
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
except Exception as e:
quota_charge.refund()
raise ScheduleExecutionError(
f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}"
) from e
quota_charge.commit()
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
except Exception as e:
quota_charge.refund()
raise ScheduleExecutionError(
f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}"
) from e
@@ -163,11 +163,9 @@ class DifyTestContainers:
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
logger.info("Redis container is ready and accepting connections")
# Start Dify Sandbox container for code execution environment
# Dify Sandbox provides a secure environment for executing user code
# Use pinned version 0.2.12 to match production docker-compose configuration
# Start Dify Sandbox container for code execution environment.
logger.info("Initializing Dify Sandbox container...")
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.12").with_network(self.network)
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.14").with_network(self.network)
self.dify_sandbox.with_exposed_ports(8194)
self.dify_sandbox.env = {
"API_KEY": "test_api_key",
@@ -187,7 +185,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.3-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)
@@ -36,12 +36,19 @@ class TestAppGenerateService:
) as mock_message_based_generator,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config,
patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config,
patch("configs.dify_config", autospec=True) as mock_global_dify_config,
):
# Setup default mock returns for billing service
mock_billing_service.update_tenant_feature_plan_usage.return_value = {
"result": "success",
"history_id": "test_history_id",
mock_billing_service.quota_reserve.return_value = {
"reservation_id": "test-reservation-id",
"available": 100,
"reserved": 1,
}
mock_billing_service.quota_commit.return_value = {
"available": 99,
"reserved": 0,
"refunded": 0,
}
# Setup default mock returns for workflow service
@@ -101,6 +108,8 @@ class TestAppGenerateService:
mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100
mock_dify_config.APP_DAILY_RATE_LIMIT = 1000
mock_quota_dify_config.BILLING_ENABLED = False
mock_global_dify_config.BILLING_ENABLED = False
mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100
mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000
@@ -118,6 +127,7 @@ class TestAppGenerateService:
"message_based_generator": mock_message_based_generator,
"account_feature_service": mock_account_feature_service,
"dify_config": mock_dify_config,
"quota_dify_config": mock_quota_dify_config,
"global_dify_config": mock_global_dify_config,
}
@@ -465,6 +475,7 @@ class TestAppGenerateService:
# Set BILLING_ENABLED to True for this test
mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True
mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True
mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True
# Setup test arguments
@@ -478,8 +489,10 @@ class TestAppGenerateService:
# Verify the result
assert result == ["test_response"]
# Verify billing service was called to consume quota
mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once()
# Verify billing two-phase quota (reserve + commit)
billing = mock_external_service_dependencies["billing_service"]
billing.quota_reserve.assert_called_once()
billing.quota_commit.assert_called_once()
def test_generate_with_invalid_app_mode(
self, db_session_with_containers: Session, mock_external_service_dependencies
@@ -0,0 +1,517 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
from enums.quota_type import QuotaType
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import AppTriggerStatus, AppTriggerType
from models.model import App
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from models.workflow import Workflow
from services.errors.app import QuotaExceededError
from services.trigger.webhook_service import WebhookService
class WebhookServiceRelationshipFactory:
@staticmethod
def create_account_and_tenant(db_session_with_containers: Session) -> tuple[Account, Tenant]:
account = Account(
name=f"Account {uuid4()}",
email=f"webhook-{uuid4()}@example.com",
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
db_session_with_containers.add(account)
db_session_with_containers.commit()
tenant = Tenant(name=f"Tenant {uuid4()}", plan="basic", status="normal")
db_session_with_containers.add(tenant)
db_session_with_containers.commit()
join = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
role=TenantAccountRole.OWNER,
current=True,
)
db_session_with_containers.add(join)
db_session_with_containers.commit()
account.current_tenant = tenant
return account, tenant
@staticmethod
def create_app(db_session_with_containers: Session, tenant: Tenant, account: Account) -> App:
app = App(
tenant_id=tenant.id,
name=f"Webhook App {uuid4()}",
description="",
mode="workflow",
icon_type="emoji",
icon="bot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=True,
api_rpm=100,
api_rph=100,
is_demo=False,
is_public=False,
is_universal=False,
created_by=account.id,
updated_by=account.id,
)
db_session_with_containers.add(app)
db_session_with_containers.commit()
return app
@staticmethod
def create_workflow(
db_session_with_containers: Session,
*,
app: App,
account: Account,
node_ids: list[str],
version: str,
) -> Workflow:
graph = {
"nodes": [
{
"id": node_id,
"data": {
"type": TRIGGER_WEBHOOK_NODE_TYPE,
"title": f"Webhook {node_id}",
"method": "post",
"content_type": "application/json",
"headers": [],
"params": [],
"body": [],
"status_code": 200,
"response_body": '{"status": "ok"}',
"timeout": 30,
},
}
for node_id in node_ids
],
"edges": [],
}
workflow = Workflow(
tenant_id=app.tenant_id,
app_id=app.id,
type="workflow",
graph=json.dumps(graph),
features=json.dumps({}),
created_by=account.id,
updated_by=account.id,
environment_variables=[],
conversation_variables=[],
version=version,
)
db_session_with_containers.add(workflow)
db_session_with_containers.commit()
return workflow
@staticmethod
def create_webhook_trigger(
db_session_with_containers: Session,
*,
app: App,
account: Account,
node_id: str,
webhook_id: str | None = None,
) -> WorkflowWebhookTrigger:
webhook_trigger = WorkflowWebhookTrigger(
app_id=app.id,
node_id=node_id,
tenant_id=app.tenant_id,
webhook_id=webhook_id or uuid4().hex[:24],
created_by=account.id,
)
db_session_with_containers.add(webhook_trigger)
db_session_with_containers.commit()
return webhook_trigger
@staticmethod
def create_app_trigger(
db_session_with_containers: Session,
*,
app: App,
node_id: str,
status: AppTriggerStatus,
) -> AppTrigger:
app_trigger = AppTrigger(
tenant_id=app.tenant_id,
app_id=app.id,
node_id=node_id,
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
provider_name="webhook",
title=f"Webhook {node_id}",
status=status,
)
db_session_with_containers.add(app_trigger)
db_session_with_containers.commit()
return app_trigger
class TestWebhookServiceLookupWithContainers:
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_missing(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
with pytest.raises(ValueError, match="App trigger not found"):
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_rate_limited(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
factory.create_app_trigger(
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED
)
with pytest.raises(ValueError, match="rate limited"):
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
factory.create_app_trigger(
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.DISABLED
)
with pytest.raises(ValueError, match="disabled"):
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
def test_get_webhook_trigger_and_workflow_raises_when_workflow_missing(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
factory.create_app_trigger(
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
)
with pytest.raises(ValueError, match="Workflow not found"):
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
factory.create_workflow(
db_session_with_containers,
app=app,
account=account,
node_ids=["published-node"],
version="2026-04-14.001",
)
draft_workflow = factory.create_workflow(
db_session_with_containers,
app=app,
account=account,
node_ids=["debug-node"],
version=Workflow.VERSION_DRAFT,
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="debug-node"
)
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
webhook_trigger.webhook_id,
is_debug=True,
)
assert got_trigger.id == webhook_trigger.id
assert got_workflow.id == draft_workflow.id
assert got_node_config["id"] == "debug-node"
class TestWebhookServiceTriggerExecutionWithContainers:
def test_trigger_workflow_execution_triggers_async_workflow_successfully(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
end_user = SimpleNamespace(id=str(uuid4()))
webhook_data = {"body": {"value": 1}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}
quota_charge = MagicMock()
with (
patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
return_value=end_user,
),
patch(
"services.trigger.webhook_service.QuotaService.reserve",
return_value=quota_charge,
) as mock_reserve,
patch("services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async") as mock_trigger,
):
WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)
mock_reserve.assert_called_once()
reserve_args = mock_reserve.call_args.args
assert reserve_args[0] == QuotaType.TRIGGER
assert reserve_args[1] == webhook_trigger.tenant_id
quota_charge.commit.assert_called_once()
mock_trigger.assert_called_once()
trigger_args = mock_trigger.call_args.args
assert trigger_args[1] is end_user
assert trigger_args[2].workflow_id == workflow.id
assert trigger_args[2].root_node_id == webhook_trigger.node_id
def test_trigger_workflow_execution_marks_tenant_rate_limited_when_quota_exceeded(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
with (
patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
return_value=SimpleNamespace(id=str(uuid4())),
),
patch(
"services.trigger.webhook_service.QuotaService.reserve",
side_effect=QuotaExceededError(feature="trigger", tenant_id=tenant.id, required=1),
),
patch(
"services.trigger.webhook_service.AppTriggerService.mark_tenant_triggers_rate_limited"
) as mock_mark_rate_limited,
):
with pytest.raises(QuotaExceededError):
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
workflow,
)
mock_mark_rate_limited.assert_called_once_with(tenant.id)
def test_trigger_workflow_execution_logs_and_reraises_unexpected_errors(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
with (
patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
side_effect=RuntimeError("boom"),
),
patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception,
):
with pytest.raises(RuntimeError, match="boom"):
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
workflow,
)
mock_logger_exception.assert_called_once()
class TestWebhookServiceRelationshipSyncWithContainers:
def test_sync_webhook_relationships_raises_when_workflow_exceeds_node_limit(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
node_ids = [f"node-{index}" for index in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1)]
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=node_ids, version=Workflow.VERSION_DRAFT
)
with pytest.raises(ValueError, match="maximum webhook node limit"):
WebhookService.sync_webhook_relationships(app, workflow)
def test_sync_webhook_relationships_raises_when_lock_not_acquired(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version=Workflow.VERSION_DRAFT
)
lock = MagicMock()
lock.acquire.return_value = False
with patch("services.trigger.webhook_service.redis_client.lock", return_value=lock):
with pytest.raises(RuntimeError, match="Failed to acquire lock"):
WebhookService.sync_webhook_relationships(app, workflow)
def test_sync_webhook_relationships_creates_missing_records_and_deletes_stale_records(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
stale_trigger = factory.create_webhook_trigger(
db_session_with_containers,
app=app,
account=account,
node_id="node-stale",
webhook_id="stale-webhook-id-000001",
)
stale_trigger_id = stale_trigger.id
workflow = factory.create_workflow(
db_session_with_containers,
app=app,
account=account,
node_ids=["node-new"],
version=Workflow.VERSION_DRAFT,
)
with patch(
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="new-webhook-id-000001"
):
WebhookService.sync_webhook_relationships(app, workflow)
db_session_with_containers.expire_all()
records = db_session_with_containers.scalars(
select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app.id)
).all()
assert [record.node_id for record in records] == ["node-new"]
assert records[0].webhook_id == "new-webhook-id-000001"
assert db_session_with_containers.get(WorkflowWebhookTrigger, stale_trigger_id) is None
def test_sync_webhook_relationships_sets_redis_cache_for_new_record(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers,
app=app,
account=account,
node_ids=["node-cache"],
version=Workflow.VERSION_DRAFT,
)
cache_key = f"{WebhookService.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:node-cache"
with patch(
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="cache-webhook-id-00001"
):
WebhookService.sync_webhook_relationships(app, workflow)
cached_payload = WebhookServiceRelationshipFactory._read_cache(cache_key)
assert cached_payload is not None
assert cached_payload["node_id"] == "node-cache"
assert cached_payload["webhook_id"] == "cache-webhook-id-00001"
def test_sync_webhook_relationships_logs_when_lock_release_fails(
self, db_session_with_containers: Session, flask_app_with_containers
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=[], version=Workflow.VERSION_DRAFT
)
lock = MagicMock()
lock.acquire.return_value = True
lock.release.side_effect = RuntimeError("release failed")
with (
patch("services.trigger.webhook_service.redis_client.lock", return_value=lock),
patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception,
):
WebhookService.sync_webhook_relationships(app, workflow)
mock_logger_exception.assert_called_once()
def _read_cache(cache_key: str) -> dict[str, str] | None:
from extensions.ext_redis import redis_client
cached = redis_client.get(cache_key)
if not cached:
return None
if isinstance(cached, bytes):
cached = cached.decode("utf-8")
return json.loads(cached)
WebhookServiceRelationshipFactory._read_cache = staticmethod(_read_cache)
@@ -602,9 +602,9 @@ def test_schedule_trigger_creates_trigger_log(
)
# Mock quota to avoid rate limiting
from enums import quota_type
from services import quota_service
monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited())
monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited())
# Execute schedule trigger
workflow_schedule_tasks.run_schedule_trigger(plan.id)
@@ -0,0 +1 @@
@@ -0,0 +1,245 @@
"""Unit tests for inner_api app DSL import/export endpoints.
Tests Pydantic model validation, endpoint handler logic, and the
_get_active_account helper. Auth/setup decorators are tested separately
in test_auth_wraps.py; handler tests use inspect.unwrap() to bypass them.
"""
import inspect
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.app.dsl import (
EnterpriseAppDSLExport,
EnterpriseAppDSLImport,
InnerAppDSLImportPayload,
_get_active_account,
)
from services.app_dsl_service import ImportStatus
class TestInnerAppDSLImportPayload:
"""Test InnerAppDSLImportPayload Pydantic model validation."""
def test_valid_payload_all_fields(self):
data = {
"yaml_content": "version: 0.6.0\nkind: app\n",
"creator_email": "user@example.com",
"name": "My App",
"description": "A test app",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.yaml_content == data["yaml_content"]
assert payload.creator_email == "user@example.com"
assert payload.name == "My App"
assert payload.description == "A test app"
def test_valid_payload_optional_fields_omitted(self):
data = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.name is None
assert payload.description is None
def test_missing_yaml_content_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"creator_email": "a@b.com"})
assert "yaml_content" in str(exc_info.value)
def test_missing_creator_email_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"yaml_content": "test"})
assert "creator_email" in str(exc_info.value)
class TestGetActiveAccount:
"""Test the _get_active_account helper function."""
@patch("controllers.inner_api.app.dsl.db")
def test_returns_active_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "active"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("user@example.com")
assert result is mock_account
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_inactive_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "banned"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("banned@example.com")
assert result is None
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_nonexistent_email(self, mock_db):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
result = _get_active_account("missing@example.com")
assert result is None
class TestEnterpriseAppDSLImport:
"""Test EnterpriseAppDSLImport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLImport()
@pytest.fixture
def _mock_import_deps(self):
"""Patch db, Session, and AppDslService for import handler tests."""
with (
patch("controllers.inner_api.app.dsl.db"),
patch("controllers.inner_api.app.dsl.Session") as mock_session,
patch("controllers.inner_api.app.dsl.AppDslService") as mock_dsl_cls,
):
mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_session.return_value.__exit__ = MagicMock(return_value=False)
self._mock_dsl = MagicMock()
mock_dsl_cls.return_value = self._mock_dsl
yield
def _make_import_result(self, status: ImportStatus, **kwargs) -> "Import":
from services.app_dsl_service import Import
result = Import(
id="import-id",
status=status,
app_id=kwargs.get("app_id", "app-123"),
app_mode=kwargs.get("app_mode", "workflow"),
)
return result
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_success_returns_200(self, mock_get_account, api_instance, app: Flask):
mock_account = MagicMock()
mock_get_account.return_value = mock_account
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 200
assert body["status"] == "completed"
mock_account.set_tenant_id.assert_called_once_with("ws-123")
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_pending_returns_202(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 202
assert body["status"] == "pending"
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_failed_returns_400(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 400
assert body["status"] == "failed"
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_account_not_found_returns_404(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = None
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 404
assert "missing@e.com" in body["message"]
class TestEnterpriseAppDSLExport:
"""Test EnterpriseAppDSLExport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLExport()
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
assert body["data"] == "version: 0.6.0\nkind: app\n"
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=False)
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "yaml-data"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=true"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=True)
@patch("controllers.inner_api.app.dsl.db")
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="nonexistent")
body, status_code = result
assert status_code == 404
assert "app not found" in body["message"]
@@ -8,6 +8,7 @@ import core.app.apps.pipeline.pipeline_generator as module
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.datasource.entities.datasource_entities import DatasourceProviderType
from models.enums import DataSourceType
class FakeRagPipelineGenerateEntity(SimpleNamespace):
@@ -558,6 +559,24 @@ def test_build_document_sets_metadata_for_builtin_fields(generator, mocker):
assert document.doc_metadata
def test_build_document_supports_online_drive_datasource_type(generator):
document = generator._build_document(
tenant_id="tenant",
dataset_id="ds",
built_in_field_enabled=True,
datasource_type=DatasourceProviderType.ONLINE_DRIVE,
datasource_info={"id": "file-1", "bucket": "bucket-1", "name": "drive.pdf", "type": "file"},
created_from="rag-pipeline",
position=1,
account=_build_user(),
batch="batch",
document_form="text",
)
assert DataSourceType(document.data_source_type) == DataSourceType.ONLINE_DRIVE
assert document.name == "drive.pdf"
def test_build_document_invalid_datasource_type(generator):
with pytest.raises(ValueError):
generator._build_document(
@@ -115,14 +115,12 @@ class TestTidbOnQdrantVectorDeleteByIds:
assert exc_info.value.status_code == 500
def test_delete_by_ids_with_large_batch(self, vector_instance):
"""Test deletion with a large batch of IDs."""
# Create 1000 IDs
def test_delete_by_ids_with_exactly_1000(self, vector_instance):
"""Test deletion with exactly 1000 IDs triggers a single batch."""
ids = [f"doc_{i}" for i in range(1000)]
vector_instance.delete_by_ids(ids)
# Verify single delete call with all IDs
vector_instance._client.delete.assert_called_once()
call_args = vector_instance._client.delete.call_args
@@ -130,11 +128,28 @@ class TestTidbOnQdrantVectorDeleteByIds:
filter_obj = filter_selector.filter
field_condition = filter_obj.must[0]
# Verify all 1000 IDs are in the batch
assert len(field_condition.match.any) == 1000
assert "doc_0" in field_condition.match.any
assert "doc_999" in field_condition.match.any
def test_delete_by_ids_splits_into_batches(self, vector_instance):
"""Test deletion with >1000 IDs triggers multiple batched calls."""
ids = [f"doc_{i}" for i in range(2500)]
vector_instance.delete_by_ids(ids)
assert vector_instance._client.delete.call_count == 3
batches = []
for call in vector_instance._client.delete.call_args_list:
filter_selector = call[1]["points_selector"]
field_condition = filter_selector.filter.must[0]
batches.append(field_condition.match.any)
assert len(batches[0]) == 1000
assert len(batches[1]) == 1000
assert len(batches[2]) == 500
def test_delete_by_ids_filter_structure(self, vector_instance):
"""Test that the filter structure is correctly constructed."""
ids = ["doc1", "doc2"]
@@ -158,3 +173,57 @@ class TestTidbOnQdrantVectorDeleteByIds:
# Verify MatchAny structure
assert isinstance(field_condition.match, rest.MatchAny)
assert field_condition.match.any == ids
class TestInitVectorEndpointSelection:
"""Test that init_vector selects the correct qdrant endpoint.
We avoid importing the full module (which triggers Flask app context)
by testing the endpoint selection logic directly on TidbOnQdrantConfig.
"""
def test_uses_binding_endpoint_when_present(self):
binding_endpoint = "https://qdrant-custom.tidb.com"
global_url = "https://qdrant-global.tidb.com"
qdrant_url = binding_endpoint or global_url or ""
assert qdrant_url == "https://qdrant-custom.tidb.com"
config = TidbOnQdrantConfig(endpoint=qdrant_url)
assert config.endpoint == "https://qdrant-custom.tidb.com"
def test_falls_back_to_global_when_binding_endpoint_is_none(self):
binding_endpoint = None
global_url = "https://qdrant-global.tidb.com"
qdrant_url = binding_endpoint or global_url or ""
assert qdrant_url == "https://qdrant-global.tidb.com"
config = TidbOnQdrantConfig(endpoint=qdrant_url)
assert config.endpoint == "https://qdrant-global.tidb.com"
def test_falls_back_to_empty_when_both_none(self):
binding_endpoint = None
global_url = None
qdrant_url = binding_endpoint or global_url or ""
assert qdrant_url == ""
config = TidbOnQdrantConfig(endpoint=qdrant_url)
assert config.endpoint == ""
def test_binding_endpoint_takes_precedence_over_global(self):
binding_endpoint = "https://qdrant-ap-southeast.tidb.com"
global_url = "https://qdrant-us-east.tidb.com"
qdrant_url = binding_endpoint or global_url or ""
assert qdrant_url == "https://qdrant-ap-southeast.tidb.com"
def test_empty_string_binding_endpoint_falls_back_to_global(self):
binding_endpoint = ""
global_url = "https://qdrant-global.tidb.com"
qdrant_url = binding_endpoint or global_url or ""
assert qdrant_url == "https://qdrant-global.tidb.com"
@@ -0,0 +1,308 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService
from models.enums import TidbAuthBindingStatus
class TestExtractQdrantEndpoint:
"""Unit tests for TidbService.extract_qdrant_endpoint."""
def test_returns_endpoint_when_host_present(self):
response = {"endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}}}
result = TidbService.extract_qdrant_endpoint(response)
assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com"
def test_returns_none_when_host_missing(self):
response = {"endpoints": {"public": {}}}
assert TidbService.extract_qdrant_endpoint(response) is None
def test_returns_none_when_public_missing(self):
response = {"endpoints": {}}
assert TidbService.extract_qdrant_endpoint(response) is None
def test_returns_none_when_endpoints_missing(self):
assert TidbService.extract_qdrant_endpoint({}) is None
class TestFetchQdrantEndpoint:
"""Unit tests for TidbService.fetch_qdrant_endpoint."""
@patch.object(TidbService, "get_tidb_serverless_cluster")
def test_returns_endpoint_when_host_present(self, mock_get_cluster):
mock_get_cluster.return_value = {
"endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}}
}
result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123")
assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com"
@patch.object(TidbService, "get_tidb_serverless_cluster")
def test_returns_none_when_cluster_response_is_none(self, mock_get_cluster):
mock_get_cluster.return_value = None
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
@patch.object(TidbService, "get_tidb_serverless_cluster")
def test_returns_none_when_host_missing(self, mock_get_cluster):
mock_get_cluster.return_value = {"endpoints": {"public": {}}}
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
@patch.object(TidbService, "get_tidb_serverless_cluster")
def test_returns_none_when_endpoints_missing(self, mock_get_cluster):
mock_get_cluster.return_value = {}
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
@patch.object(TidbService, "get_tidb_serverless_cluster")
def test_returns_none_on_exception(self, mock_get_cluster):
mock_get_cluster.side_effect = RuntimeError("network error")
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
class TestCreateTidbServerlessClusterQdrantEndpoint:
"""Verify that create_tidb_serverless_cluster includes qdrant_endpoint in its result."""
@patch.object(TidbService, "get_tidb_serverless_cluster")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_get_cluster):
mock_config.TIDB_SPEND_LIMIT = 10
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
mock_get_cluster.return_value = {
"state": "ACTIVE",
"userPrefix": "pfx",
"endpoints": {"public": {"host": "gw.tidbcloud.com", "port": 4000}},
}
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
assert result is not None
assert result["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com"
@patch.object(TidbService, "get_tidb_serverless_cluster")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_result_qdrant_endpoint_none_when_no_endpoints(self, mock_config, mock_http, mock_get_cluster):
mock_config.TIDB_SPEND_LIMIT = 10
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"}
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
assert result is not None
assert result["qdrant_endpoint"] is None
class TestBatchCreateTidbServerlessClusterQdrantEndpoint:
"""Verify that batch_create includes qdrant_endpoint per cluster."""
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_batch_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_redis, mock_fetch_ep):
mock_config.TIDB_SPEND_LIMIT = 10
cluster_name = "abc123"
mock_http.post.return_value = MagicMock(
status_code=200,
json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": cluster_name}]},
)
mock_redis.setex = MagicMock()
mock_redis.get.return_value = b"password123"
result = TidbService.batch_create_tidb_serverless_cluster(
batch_size=1,
project_id="proj",
api_url="url",
iam_url="iam",
public_key="pub",
private_key="priv",
region="us-east-1",
)
assert len(result) == 1
assert result[0]["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com"
class TestCreateTidbServerlessClusterRetry:
"""Cover retry/logging paths in create_tidb_serverless_cluster."""
@patch.object(TidbService, "get_tidb_serverless_cluster")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_polls_until_active(self, mock_config, mock_http, mock_get_cluster):
mock_config.TIDB_SPEND_LIMIT = 10
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
mock_get_cluster.side_effect = [
{"state": "CREATING", "userPrefix": ""},
{"state": "ACTIVE", "userPrefix": "pfx", "endpoints": {"public": {"host": "gw.tidb.com"}}},
]
with patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.time.sleep"):
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
assert result is not None
assert result["qdrant_endpoint"] == "https://qdrant-gw.tidb.com"
assert mock_get_cluster.call_count == 2
@patch.object(TidbService, "get_tidb_serverless_cluster")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_returns_none_after_max_retries(self, mock_config, mock_http, mock_get_cluster):
mock_config.TIDB_SPEND_LIMIT = 10
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
mock_get_cluster.return_value = {"state": "CREATING", "userPrefix": ""}
with patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.time.sleep"):
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
assert result is None
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_raises_on_post_failure(self, mock_config, mock_http):
mock_config.TIDB_SPEND_LIMIT = 10
mock_response = MagicMock(status_code=400, text="Bad Request")
mock_response.raise_for_status.side_effect = Exception("HTTP 400")
mock_http.post.return_value = mock_response
with pytest.raises(Exception, match="HTTP 400"):
TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
class TestBatchCreateEdgeCases:
"""Cover logging/edge-case branches in batch_create."""
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None)
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_skips_cluster_when_no_cached_password(self, mock_config, mock_http, mock_redis, mock_fetch_ep):
mock_config.TIDB_SPEND_LIMIT = 10
mock_http.post.return_value = MagicMock(
status_code=200,
json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": "name1"}]},
)
mock_redis.setex = MagicMock()
mock_redis.get.return_value = None
result = TidbService.batch_create_tidb_serverless_cluster(
batch_size=1,
project_id="proj",
api_url="url",
iam_url="iam",
public_key="pub",
private_key="priv",
region="us-east-1",
)
assert len(result) == 0
mock_fetch_ep.assert_not_called()
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
def test_raises_on_post_failure(self, mock_config, mock_http, mock_redis):
mock_config.TIDB_SPEND_LIMIT = 10
mock_response = MagicMock(status_code=500, text="Server Error")
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_http.post.return_value = mock_response
mock_redis.setex = MagicMock()
with pytest.raises(Exception, match="HTTP 500"):
TidbService.batch_create_tidb_serverless_cluster(
batch_size=1,
project_id="proj",
api_url="url",
iam_url="iam",
public_key="pub",
private_key="priv",
region="us-east-1",
)
class TestBatchUpdateTidbServerlessClusterStatus:
"""Verify that status updates only expose clusters after qdrant endpoint is ready."""
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
def test_sets_active_when_batch_response_contains_endpoint(self, mock_http, mock_db):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {
"clusters": [
{
"clusterId": "c-1",
"state": "ACTIVE",
"userPrefix": "pfx",
"endpoints": {"public": {"host": "gw.tidbcloud.com"}},
}
]
},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
assert binding.status == TidbAuthBindingStatus.ACTIVE
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
def test_fetches_endpoint_when_batch_response_omits_it(self, mock_http, mock_db, mock_fetch_endpoint):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {
"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]
},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
assert binding.status == TidbAuthBindingStatus.ACTIVE
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None)
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
def test_keeps_creating_when_endpoint_is_not_ready(self, mock_http, mock_db, mock_fetch_endpoint):
binding = SimpleNamespace(
cluster_id="c-1",
status=TidbAuthBindingStatus.CREATING,
account="root",
qdrant_endpoint=None,
)
mock_http.get.return_value = MagicMock(
status_code=200,
json=lambda: {
"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]
},
)
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
assert binding.account == "pfx.root"
assert binding.qdrant_endpoint is None
assert binding.status == TidbAuthBindingStatus.CREATING
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
mock_db.session.add.assert_called_once_with(binding)
mock_db.session.commit.assert_called_once()
@@ -0,0 +1,349 @@
"""Unit tests for QuotaType, QuotaService, and QuotaCharge."""
from unittest.mock import patch
import pytest
from enums.quota_type import QuotaType
from services.quota_service import QuotaCharge, QuotaService, unlimited
class TestQuotaType:
def test_billing_key_trigger(self):
assert QuotaType.TRIGGER.billing_key == "trigger_event"
def test_billing_key_workflow(self):
assert QuotaType.WORKFLOW.billing_key == "api_rate_limit"
def test_billing_key_unlimited_raises(self):
with pytest.raises(ValueError, match="Invalid quota type"):
_ = QuotaType.UNLIMITED.billing_key
class TestQuotaService:
def test_reserve_billing_disabled(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService"),
):
mock_cfg.BILLING_ENABLED = False
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
assert charge.success is True
assert charge.charge_id is None
def test_reserve_zero_amount_raises(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = True
with pytest.raises(ValueError, match="greater than 0"):
QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0)
def test_reserve_success(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99}
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1)
assert charge.success is True
assert charge.charge_id == "rid-1"
assert charge._tenant_id == "t1"
assert charge._feature_key == "trigger_event"
assert charge._amount == 1
mock_bs.quota_reserve.assert_called_once()
def test_reserve_no_reservation_id_raises(self):
from services.errors.app import QuotaExceededError
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {}
with pytest.raises(QuotaExceededError):
QuotaService.reserve(QuotaType.TRIGGER, "t1")
def test_reserve_quota_exceeded_propagates(self):
from services.errors.app import QuotaExceededError
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1)
with pytest.raises(QuotaExceededError):
QuotaService.reserve(QuotaType.TRIGGER, "t1")
def test_reserve_api_exception_returns_unlimited(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.side_effect = RuntimeError("network")
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
assert charge.success is True
assert charge.charge_id is None
def test_consume_calls_reserve_and_commit(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"}
mock_bs.quota_commit.return_value = {}
charge = QuotaService.consume(QuotaType.TRIGGER, "t1")
assert charge.success is True
mock_bs.quota_commit.assert_called_once()
def test_check_billing_disabled(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = False
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
def test_check_zero_amount_raises(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = True
with pytest.raises(ValueError, match="greater than 0"):
QuotaService.check(QuotaType.TRIGGER, "t1", amount=0)
def test_check_sufficient_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=100),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True
def test_check_insufficient_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=5),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False
def test_check_unlimited_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=-1),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True
def test_check_exception_returns_true(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", side_effect=RuntimeError),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
def test_release_billing_disabled(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = False
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
mock_bs.quota_release.assert_not_called()
def test_release_empty_reservation(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event")
mock_bs.quota_release.assert_not_called()
def test_release_success(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_release.return_value = {}
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
mock_bs.quota_release.assert_called_once_with(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1"
)
def test_release_exception_swallowed(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_release.side_effect = RuntimeError("fail")
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
def test_get_remaining_normal(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70
def test_get_remaining_unlimited(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
def test_get_remaining_over_limit_returns_zero(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_exception_returns_neg1(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.side_effect = RuntimeError
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
def test_get_remaining_empty_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_non_dict_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = "invalid"
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_feature_not_in_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}}
remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1")
assert remaining == 0
def test_get_remaining_non_dict_feature_info(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
class TestQuotaCharge:
def test_commit_success(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
mock_bs.quota_commit.assert_called_once_with(
tenant_id="t1",
feature_key="trigger_event",
reservation_id="rid-1",
actual_amount=1,
)
assert charge._committed is True
def test_commit_with_actual_amount(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=10,
)
charge.commit(actual_amount=5)
call_kwargs = mock_bs.quota_commit.call_args[1]
assert call_kwargs["actual_amount"] == 5
def test_commit_idempotent(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
charge.commit()
assert mock_bs.quota_commit.call_count == 1
def test_commit_no_charge_id_noop(self):
with patch("services.billing_service.BillingService") as mock_bs:
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
charge.commit()
mock_bs.quota_commit.assert_not_called()
def test_commit_no_tenant_id_noop(self):
with patch("services.billing_service.BillingService") as mock_bs:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id=None,
_feature_key="trigger_event",
)
charge.commit()
mock_bs.quota_commit.assert_not_called()
def test_commit_exception_swallowed(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.side_effect = RuntimeError("fail")
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
def test_refund_success(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
)
charge.refund()
mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
def test_refund_no_charge_id_noop(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
charge.refund()
mock_rel.assert_not_called()
def test_refund_no_tenant_id_noop(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id=None,
)
charge.refund()
mock_rel.assert_not_called()
class TestUnlimited:
def test_unlimited_returns_success_with_no_charge_id(self):
charge = unlimited()
assert charge.success is True
assert charge.charge_id is None
assert charge._quota_type == QuotaType.UNLIMITED
@@ -23,6 +23,7 @@ import pytest
import services.app_generate_service as ags_module
from core.app.entities.app_invoke_entities import InvokeFrom
from enums.quota_type import QuotaType
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
@@ -447,8 +448,8 @@ class TestGenerateBilling:
def test_billing_enabled_consumes_quota(self, mocker, monkeypatch):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
consume_mock = mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
reserve_mock = mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
mocker.patch(
@@ -467,7 +468,8 @@ class TestGenerateBilling:
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
consume_mock.assert_called_once_with("tenant-id")
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
quota_charge.commit.assert_called_once()
def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch):
from services.errors.app import QuotaExceededError
@@ -475,7 +477,7 @@ class TestGenerateBilling:
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
"services.app_generate_service.QuotaService.reserve",
side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1),
)
@@ -492,7 +494,7 @@ class TestGenerateBilling:
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
mocker.patch(
@@ -57,7 +57,7 @@ class TestAsyncWorkflowService:
- repo: SQLAlchemyWorkflowTriggerLogRepository
- dispatcher_manager_class: QueueDispatcherManager class
- dispatcher: dispatcher instance
- quota_workflow: QuotaType.WORKFLOW
- quota_service: QuotaService mock
- get_workflow: AsyncWorkflowService._get_workflow method
- professional_task: execute_workflow_professional
- team_task: execute_workflow_team
@@ -72,12 +72,7 @@ class TestAsyncWorkflowService:
mock_repo.create.side_effect = _create_side_effect
mock_dispatcher = MagicMock()
quota_workflow = MagicMock()
mock_get_workflow = MagicMock()
mock_professional_task = MagicMock()
mock_team_task = MagicMock()
mock_sandbox_task = MagicMock()
mock_quota_service = MagicMock()
with (
patch.object(
@@ -93,8 +88,8 @@ class TestAsyncWorkflowService:
) as mock_get_workflow,
patch.object(
async_workflow_service_module,
"QuotaType",
new=SimpleNamespace(WORKFLOW=quota_workflow),
"QuotaService",
new=mock_quota_service,
),
patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task,
patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task,
@@ -107,7 +102,7 @@ class TestAsyncWorkflowService:
"repo": mock_repo,
"dispatcher_manager_class": mock_dispatcher_manager_class,
"dispatcher": mock_dispatcher,
"quota_workflow": quota_workflow,
"quota_service": mock_quota_service,
"get_workflow": mock_get_workflow,
"professional_task": mock_professional_task,
"team_task": mock_team_task,
@@ -146,6 +141,9 @@ class TestAsyncWorkflowService:
mocks["team_task"].delay.return_value = task_result
mocks["sandbox_task"].delay.return_value = task_result
quota_charge_mock = MagicMock()
mocks["quota_service"].reserve.return_value = quota_charge_mock
class DummyAccount:
def __init__(self, user_id: str):
self.id = user_id
@@ -163,8 +161,9 @@ class TestAsyncWorkflowService:
assert result.status == "queued"
assert result.queue == queue_name
mocks["quota_workflow"].consume.assert_called_once_with("tenant-123")
assert session.commit.call_count == 2
mocks["quota_service"].reserve.assert_called_once()
quota_charge_mock.commit.assert_called_once()
assert session.commit.call_count == 3
created_log = mocks["repo"].create.call_args[0][0]
assert created_log.status == WorkflowTriggerStatus.QUEUED
@@ -250,7 +249,7 @@ class TestAsyncWorkflowService:
mocks = async_workflow_trigger_mocks
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM
mocks["get_workflow"].return_value = workflow
mocks["quota_workflow"].consume.side_effect = QuotaExceededError(
mocks["quota_service"].reserve.side_effect = QuotaExceededError(
feature="workflow",
tenant_id="tenant-123",
required=1,
@@ -267,7 +266,7 @@ class TestAsyncWorkflowService:
trigger_data=trigger_data,
)
assert session.commit.call_count == 2
assert session.commit.call_count == 3
updated_log = mocks["repo"].update.call_args[0][0]
assert updated_log.status == WorkflowTriggerStatus.RATE_LIMITED
assert "Quota limit reached" in updated_log.error
@@ -463,7 +462,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
# Assert
assert result == workflow
workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123")
workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123", session=None)
workflow_service.get_published_workflow.assert_not_called()
def test_should_raise_when_specific_workflow_id_not_found(self):
@@ -491,7 +490,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
# Assert
assert result == workflow
workflow_service.get_published_workflow.assert_called_once_with(app_model)
workflow_service.get_published_workflow.assert_called_once_with(app_model, session=None)
workflow_service.get_published_workflow_by_id.assert_not_called()
def test_should_raise_when_default_published_workflow_not_found(self):
@@ -290,9 +290,19 @@ class TestBillingServiceSubscriptionInfo:
# Arrange
tenant_id = "tenant-123"
expected_response = {
"subscription_plan": "professional",
"billing_cycle": "monthly",
"status": "active",
"enabled": True,
"subscription": {"plan": "professional", "interval": "month", "education": False},
"members": {"size": 1, "limit": 50},
"apps": {"size": 1, "limit": 200},
"vector_space": {"size": 0.0, "limit": 20480},
"knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 0, "limit": 1000},
"annotation_quota_limit": {"size": 0, "limit": 5000},
"docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
"next_credit_reset_date": 1775952000,
}
mock_send_request.return_value = expected_response
@@ -415,7 +425,7 @@ class TestBillingServiceUsageCalculation:
yield mock
def test_get_tenant_feature_plan_usage_info(self, mock_send_request):
"""Test retrieval of tenant feature plan usage information."""
"""Test retrieval of tenant feature plan usage information (legacy endpoint)."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}}
@@ -428,6 +438,20 @@ class TestBillingServiceUsageCalculation:
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
def test_get_quota_info(self, mock_send_request):
"""Test retrieval of quota info from new endpoint."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}}
mock_send_request.return_value = expected_response
# Act
result = BillingService.get_quota_info(tenant_id)
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request):
"""Test updating tenant feature usage with positive delta (adding credits)."""
# Arrange
@@ -505,6 +529,150 @@ class TestBillingServiceUsageCalculation:
)
class TestBillingServiceQuotaOperations:
"""Unit tests for quota reserve/commit/release operations."""
@pytest.fixture
def mock_send_request(self):
with patch.object(BillingService, "_send_request") as mock:
yield mock
def test_quota_reserve_success(self, mock_send_request):
expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1}
mock_send_request.return_value = expected
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1)
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/reserve",
json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1},
)
def test_quota_reserve_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"}
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1)
assert result["available"] == 99
assert isinstance(result["available"], int)
assert result["reserved"] == 1
assert isinstance(result["reserved"], int)
def test_quota_reserve_with_meta(self, mock_send_request):
mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1}
meta = {"source": "webhook"}
BillingService.quota_reserve(
tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"source": "webhook"}
def test_quota_commit_success(self, mock_send_request):
expected = {"available": 98, "reserved": 0, "refunded": 0}
mock_send_request.return_value = expected
result = BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1
)
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/commit",
json={
"tenant_id": "t1",
"feature_key": "trigger_event",
"reservation_id": "rid-1",
"actual_amount": 1,
},
)
def test_quota_commit_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"}
result = BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1
)
assert result["available"] == 97
assert isinstance(result["available"], int)
assert result["refunded"] == 1
assert isinstance(result["refunded"], int)
def test_quota_commit_with_meta(self, mock_send_request):
mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0}
meta = {"reason": "partial"}
BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"reason": "partial"}
def test_quota_release_success(self, mock_send_request):
expected = {"available": 100, "reserved": 0, "released": 1}
mock_send_request.return_value = expected
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1")
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/release",
json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"},
)
def test_quota_release_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"}
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s")
assert result["available"] == 100
assert isinstance(result["available"], int)
assert result["released"] == 1
assert isinstance(result["released"], int)
def test_get_quota_info_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int for get_quota_info."""
mock_send_request.return_value = {
"trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"},
"api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"},
}
result = BillingService.get_quota_info("t1")
assert result["trigger_event"]["usage"] == 42
assert isinstance(result["trigger_event"]["usage"], int)
assert result["trigger_event"]["limit"] == 3000
assert isinstance(result["trigger_event"]["limit"], int)
assert result["trigger_event"]["reset_date"] == 1700000000
assert isinstance(result["trigger_event"]["reset_date"], int)
assert result["api_rate_limit"]["limit"] == -1
assert isinstance(result["api_rate_limit"]["limit"], int)
def test_get_quota_info_accepts_int_values(self, mock_send_request):
"""Test that get_quota_info works with native int values."""
expected = {
"trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000},
"api_rate_limit": {"usage": 0, "limit": -1},
}
mock_send_request.return_value = expected
result = BillingService.get_quota_info("t1")
assert result["trigger_event"]["usage"] == 42
assert result["trigger_event"]["limit"] == 3000
assert result["api_rate_limit"]["limit"] == -1
class TestBillingServiceRateLimitEnforcement:
"""Unit tests for rate limit enforcement mechanisms.
@@ -1009,17 +1177,14 @@ class TestBillingServiceEdgeCases:
yield mock
def test_get_info_empty_response(self, mock_send_request):
"""Test handling of empty billing info response."""
# Arrange
"""Empty response from billing API should raise ValidationError due to missing required fields."""
from pydantic import ValidationError
tenant_id = "tenant-empty"
mock_send_request.return_value = {}
# Act
result = BillingService.get_info(tenant_id)
# Assert
assert result == {}
mock_send_request.assert_called_once()
with pytest.raises(ValidationError):
BillingService.get_info(tenant_id)
def test_update_tenant_feature_plan_usage_zero_delta(self, mock_send_request):
"""Test updating tenant feature usage with zero delta (no change)."""
@@ -1434,12 +1599,21 @@ class TestBillingServiceIntegrationScenarios:
# Step 1: Get current billing info
mock_send_request.return_value = {
"subscription_plan": "sandbox",
"billing_cycle": "monthly",
"status": "active",
"enabled": True,
"subscription": {"plan": "sandbox", "interval": "", "education": False},
"members": {"size": 0, "limit": 1},
"apps": {"size": 0, "limit": 5},
"vector_space": {"size": 0.0, "limit": 50},
"knowledge_rate_limit": {"limit": 10},
"documents_upload_quota": {"size": 0, "limit": 50},
"annotation_quota_limit": {"size": 0, "limit": 10},
"docs_processing": "standard",
"can_replace_logo": False,
"model_load_balancing_enabled": False,
"knowledge_pipeline_publish_enabled": False,
}
current_info = BillingService.get_info(tenant_id)
assert current_info["subscription_plan"] == "sandbox"
assert current_info["subscription"]["plan"] == "sandbox"
# Step 2: Get payment link for upgrade
mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"}
@@ -1553,3 +1727,140 @@ class TestBillingServiceIntegrationScenarios:
mock_send_request.return_value = {"result": "success", "activated": True}
activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student")
assert activate_result["activated"] is True
class TestBillingServiceSubscriptionInfoDataType:
"""Unit tests for data type coercion in BillingService.get_info
1. Verifies the get_info returns correct Python types for numeric fields
2. Ensure the compatibility regardless of what results the upstream billing API returns
"""
@pytest.fixture
def mock_send_request(self):
with patch.object(BillingService, "_send_request") as mock:
yield mock
@pytest.fixture
def normal_billing_response(self) -> dict:
return {
"enabled": True,
"subscription": {
"plan": "team",
"interval": "year",
"education": False,
},
"members": {"size": 10, "limit": 50},
"apps": {"size": 80, "limit": 200},
"vector_space": {"size": 5120.75, "limit": 20480},
"knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 450, "limit": 1000},
"annotation_quota_limit": {"size": 1200, "limit": 5000},
"docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
"next_credit_reset_date": 1745971200,
}
@pytest.fixture
def string_billing_response(self) -> dict:
return {
"enabled": True,
"subscription": {
"plan": "team",
"interval": "year",
"education": False,
},
"members": {"size": "10", "limit": "50"},
"apps": {"size": "80", "limit": "200"},
"vector_space": {"size": 5120.75, "limit": "20480"},
"knowledge_rate_limit": {"limit": "1000"},
"documents_upload_quota": {"size": "450", "limit": "1000"},
"annotation_quota_limit": {"size": "1200", "limit": "5000"},
"docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
"next_credit_reset_date": "1745971200",
}
@staticmethod
def _assert_billing_info_types(result: dict):
assert isinstance(result["enabled"], bool)
assert isinstance(result["subscription"]["plan"], str)
assert isinstance(result["subscription"]["interval"], str)
assert isinstance(result["subscription"]["education"], bool)
assert isinstance(result["members"]["size"], int)
assert isinstance(result["members"]["limit"], int)
assert isinstance(result["apps"]["size"], int)
assert isinstance(result["apps"]["limit"], int)
assert isinstance(result["vector_space"]["size"], float)
assert isinstance(result["vector_space"]["limit"], int)
assert isinstance(result["knowledge_rate_limit"]["limit"], int)
assert isinstance(result["documents_upload_quota"]["size"], int)
assert isinstance(result["documents_upload_quota"]["limit"], int)
assert isinstance(result["annotation_quota_limit"]["size"], int)
assert isinstance(result["annotation_quota_limit"]["limit"], int)
assert isinstance(result["docs_processing"], str)
assert isinstance(result["can_replace_logo"], bool)
assert isinstance(result["model_load_balancing_enabled"], bool)
assert isinstance(result["knowledge_pipeline_publish_enabled"], bool)
if "next_credit_reset_date" in result:
assert isinstance(result["next_credit_reset_date"], int)
def test_get_info_with_normal_types(self, mock_send_request, normal_billing_response):
"""When the billing API returns native numeric types, get_info should preserve them."""
mock_send_request.return_value = normal_billing_response
result = BillingService.get_info("tenant-type-test")
self._assert_billing_info_types(result)
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
def test_get_info_with_string_types(self, mock_send_request, string_billing_response):
"""When the billing API returns numeric values as strings, get_info should coerce them."""
mock_send_request.return_value = string_billing_response
result = BillingService.get_info("tenant-type-test")
self._assert_billing_info_types(result)
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
def test_get_info_without_optional_fields(self, mock_send_request, string_billing_response):
"""NotRequired fields can be absent without raising."""
del string_billing_response["next_credit_reset_date"]
mock_send_request.return_value = string_billing_response
result = BillingService.get_info("tenant-type-test")
assert "next_credit_reset_date" not in result
self._assert_billing_info_types(result)
def test_get_info_with_extra_fields(self, mock_send_request, string_billing_response):
"""Undefined fields are silently stripped by validate_python."""
string_billing_response["new_feature"] = "something"
mock_send_request.return_value = string_billing_response
result = BillingService.get_info("tenant-type-test")
# extra fields are dropped by TypeAdapter on TypedDict
assert "new_feature" not in result
self._assert_billing_info_types(result)
def test_get_info_missing_required_field_raises(self, mock_send_request, string_billing_response):
"""Missing a required field should raise ValidationError."""
from pydantic import ValidationError
del string_billing_response["members"]
mock_send_request.return_value = string_billing_response
with pytest.raises(ValidationError):
BillingService.get_info("tenant-type-test")
@@ -337,10 +337,7 @@ class TestWorkflowService:
app = TestWorkflowAssociatedDataFactory.create_app_mock()
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
# Mock database query
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
result = workflow_service.get_draft_workflow(app)
@@ -350,10 +347,7 @@ class TestWorkflowService:
"""Test get_draft_workflow returns None when no draft exists."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
# Mock database query to return None
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = None
mock_db_session.session.scalar.return_value = None
result = workflow_service.get_draft_workflow(app)
@@ -365,10 +359,7 @@ class TestWorkflowService:
workflow_id = "workflow-123"
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
# Mock database query
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id)
@@ -383,10 +374,7 @@ class TestWorkflowService:
workflow_id = "workflow-123"
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
# Mock database query
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
@@ -405,10 +393,7 @@ class TestWorkflowService:
workflow_id=workflow_id, version=Workflow.VERSION_DRAFT
)
# Mock database query
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
with pytest.raises(IsDraftWorkflowError):
workflow_service.get_published_workflow_by_id(app, workflow_id)
@@ -418,10 +403,7 @@ class TestWorkflowService:
app = TestWorkflowAssociatedDataFactory.create_app_mock()
workflow_id = "nonexistent-workflow"
# Mock database query to return None
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = None
mock_db_session.session.scalar.return_value = None
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
@@ -433,10 +415,7 @@ class TestWorkflowService:
app = TestWorkflowAssociatedDataFactory.create_app_mock(workflow_id=workflow_id)
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
# Mock database query
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
result = workflow_service.get_published_workflow(app)
@@ -465,11 +444,7 @@ class TestWorkflowService:
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
features = {"file_upload": {"enabled": False}}
# Mock get_draft_workflow to return None (no existing draft)
# This simulates the first time a workflow is created for an app
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = None
mock_db_session.session.scalar.return_value = None
with (
patch.object(workflow_service, "validate_features_structure"),
@@ -506,9 +481,7 @@ class TestWorkflowService:
# Mock existing draft workflow
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash=unique_hash)
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
with (
patch.object(workflow_service, "validate_features_structure"),
@@ -547,9 +520,7 @@ class TestWorkflowService:
# Mock existing draft workflow with different hash
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash="old-hash")
mock_query = MagicMock()
mock_db_session.session.query.return_value = mock_query
mock_query.where.return_value.first.return_value = mock_workflow
mock_db_session.session.scalar.return_value = mock_workflow
with pytest.raises(WorkflowHashNotEqualError):
workflow_service.sync_draft_workflow(
@@ -0,0 +1,204 @@
from unittest.mock import MagicMock, patch
import pytest
import tasks.trigger_processing_tasks as trigger_processing_tasks_module
from services.errors.app import QuotaExceededError
from tasks.trigger_processing_tasks import dispatch_triggered_workflow
class TestDispatchTriggeredWorkflow:
"""Unit tests covering branch behaviours of ``dispatch_triggered_workflow``.
The covered branches are:
- workflow missing for ``plugin_trigger.app_id`` log + ``continue``
- ``QuotaService.reserve`` raising ``QuotaExceededError``
``mark_tenant_triggers_rate_limited`` + early ``return``
- ``trigger_workflow_async`` succeeds
``quota_charge.commit()`` + ``dispatched_count`` increments
"""
@pytest.fixture
def subscription(self):
sub = MagicMock()
sub.id = "subscription-123"
sub.tenant_id = "tenant-123"
sub.provider_id = "langgenius/test_plugin/test_plugin"
sub.endpoint_id = "endpoint-123"
sub.credentials = {}
sub.credential_type = "api_key"
return sub
@pytest.fixture
def plugin_trigger(self):
trigger = MagicMock()
trigger.id = "plugin-trigger-123"
trigger.app_id = "app-123"
trigger.node_id = "node-123"
return trigger
@pytest.fixture
def provider_controller(self):
controller = MagicMock()
controller.plugin_unique_identifier = "langgenius/test_plugin:0.0.1"
controller.entity.identity.name = "Test Plugin"
controller.entity.identity.icon = "icon.svg"
controller.entity.identity.icon_dark = "icon_dark.svg"
return controller
@pytest.fixture
def dispatch_mocks(self, subscription, plugin_trigger, provider_controller):
"""Patch all external dependencies reached by ``dispatch_triggered_workflow``.
Defaults are configured so the code flow can reach the final async
trigger block (line ~385); each test overrides specific handles
(``get_workflows``, ``reserve``, ``create_end_user_batch``, ...) to
drive the path it targets.
"""
session_cm = MagicMock()
session_cm.__enter__.return_value = MagicMock()
session_cm.__exit__.return_value = False
invoke_response = MagicMock()
invoke_response.cancelled = False
invoke_response.variables = {}
quota_charge = MagicMock()
with (
patch.object(
trigger_processing_tasks_module.TriggerHttpRequestCachingService,
"get_request",
return_value=MagicMock(),
),
patch.object(
trigger_processing_tasks_module.TriggerHttpRequestCachingService,
"get_payload",
return_value=MagicMock(),
),
patch.object(
trigger_processing_tasks_module.TriggerSubscriptionOperatorService,
"get_subscriber_triggers",
return_value=[plugin_trigger],
),
patch.object(
trigger_processing_tasks_module.TriggerManager,
"get_trigger_provider",
return_value=provider_controller,
),
patch.object(
trigger_processing_tasks_module.TriggerManager,
"invoke_trigger_event",
return_value=invoke_response,
) as invoke_trigger_event,
patch.object(
trigger_processing_tasks_module.TriggerEventNodeData,
"model_validate",
return_value=MagicMock(),
),
patch.object(
trigger_processing_tasks_module,
"_get_latest_workflows_by_app_ids",
) as get_workflows,
patch.object(
trigger_processing_tasks_module.EndUserService,
"create_end_user_batch",
return_value={},
) as create_end_user_batch,
patch.object(
trigger_processing_tasks_module.session_factory,
"create_session",
return_value=session_cm,
),
patch.object(
trigger_processing_tasks_module.QuotaService,
"reserve",
return_value=quota_charge,
) as reserve,
patch.object(
trigger_processing_tasks_module.AppTriggerService,
"mark_tenant_triggers_rate_limited",
) as mark_rate_limited,
patch.object(
trigger_processing_tasks_module.AsyncWorkflowService,
"trigger_workflow_async",
) as trigger_workflow_async,
):
yield {
"get_workflows": get_workflows,
"reserve": reserve,
"quota_charge": quota_charge,
"mark_rate_limited": mark_rate_limited,
"invoke_trigger_event": invoke_trigger_event,
"invoke_response": invoke_response,
"create_end_user_batch": create_end_user_batch,
"trigger_workflow_async": trigger_workflow_async,
}
def test_dispatch_skips_when_workflow_missing(self, subscription, dispatch_mocks):
"""Covers missing workflow → log + ``continue``."""
dispatch_mocks["get_workflows"].return_value = {}
dispatched = dispatch_triggered_workflow(
user_id="user-123",
subscription=subscription,
event_name="test_event",
request_id="request-123",
)
assert dispatched == 0
dispatch_mocks["reserve"].assert_not_called()
dispatch_mocks["invoke_trigger_event"].assert_not_called()
dispatch_mocks["mark_rate_limited"].assert_not_called()
def test_dispatch_marks_rate_limited_when_quota_exceeded(self, subscription, plugin_trigger, dispatch_mocks):
"""Covers QuotaExceededError → mark rate-limited + early return."""
workflow_mock = MagicMock()
workflow_mock.walk_nodes.return_value = iter(
[(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})]
)
dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock}
dispatch_mocks["reserve"].side_effect = QuotaExceededError(
feature="trigger", tenant_id=subscription.tenant_id, required=1
)
dispatched = dispatch_triggered_workflow(
user_id="user-123",
subscription=subscription,
event_name="test_event",
request_id="request-123",
)
assert dispatched == 0
dispatch_mocks["reserve"].assert_called_once()
dispatch_mocks["mark_rate_limited"].assert_called_once_with(subscription.tenant_id)
dispatch_mocks["invoke_trigger_event"].assert_not_called()
def test_dispatch_commits_quota_and_counts_when_workflow_triggered(
self, subscription, plugin_trigger, dispatch_mocks
):
"""Happy path: end user exists and async trigger succeeds."""
workflow_mock = MagicMock()
workflow_mock.id = "workflow-123"
workflow_mock.walk_nodes.return_value = iter(
[(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})]
)
dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock}
end_user_mock = MagicMock()
dispatch_mocks["create_end_user_batch"].return_value = {plugin_trigger.app_id: end_user_mock}
dispatched = dispatch_triggered_workflow(
user_id="user-123",
subscription=subscription,
event_name="test_event",
request_id="request-123",
)
assert dispatched == 1
dispatch_mocks["trigger_workflow_async"].assert_called_once()
_, kwargs = dispatch_mocks["trigger_workflow_async"].call_args
assert kwargs["user"] is end_user_mock
dispatch_mocks["quota_charge"].commit.assert_called_once()
dispatch_mocks["quota_charge"].refund.assert_not_called()
dispatch_mocks["mark_rate_limited"].assert_not_called()
Generated
+1 -1
View File
@@ -1457,7 +1457,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
+6 -6
View File
@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -245,7 +245,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -269,7 +269,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.
+2 -2
View File
@@ -97,7 +97,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
env_file:
- ./middleware.env
@@ -123,7 +123,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
env_file:
- ./middleware.env
+6 -6
View File
@@ -731,7 +731,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -773,7 +773,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -812,7 +812,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -842,7 +842,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -955,7 +955,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -979,7 +979,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.
+2 -1
View File
@@ -5,7 +5,8 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
nodejs_path: /usr/local/bin/node
enable_network: True # please make sure there is no network risk in your environment
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
proxy:
@@ -5,7 +5,7 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
python_lib_path:
- /usr/local/lib/python3.10
- /usr/lib/python3.10
@@ -501,6 +501,16 @@ describe('Question component', () => {
expect(onRegenerate).toHaveBeenCalled()
})
it('should render default question avatar icon when questionIcon is not provided', () => {
const { container } = renderWithProvider(
makeItem(),
vi.fn() as unknown as OnRegenerate,
)
const defaultIcon = container.querySelector('.question-default-user-icon')
expect(defaultIcon).toBeInTheDocument()
})
it('should render custom questionIcon when provided', () => {
const { container } = renderWithProvider(
makeItem(),
@@ -509,7 +519,7 @@ describe('Question component', () => {
)
expect(screen.getByTestId('custom-question-icon')).toBeInTheDocument()
const defaultIcon = container.querySelector('.i-custom-public-avatar-user')
const defaultIcon = container.querySelector('.question-default-user-icon')
expect(defaultIcon).not.toBeInTheDocument()
})
@@ -15,6 +15,7 @@ import {
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { FileList } from '@/app/components/base/file-uploader'
import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown'
import { cn } from '@/utils/classnames'
import ActionButton from '../../action-button'
@@ -243,7 +244,7 @@ const Question: FC<QuestionProps> = ({
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<div className="i-custom-public-avatar-user h-full w-full" />
<User className="question-default-user-icon h-full w-full" />
</div>
)
}
@@ -142,7 +142,7 @@ const ApiKeyModal = ({
onExtraButtonClick={onRemove}
disabled={disabled || isLoading || doingAction}
clickOutsideNotClose={true}
wrapperClassName="!z-[101]"
wrapperClassName="!z-[1002]"
>
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
@@ -157,7 +157,7 @@ const OAuthClientSettings = ({
)
}
containerClassName="pt-0"
wrapperClassName="!z-[101]"
wrapperClassName="!z-[1002]"
clickOutsideNotClose={true}
>
{pluginPayload.detail && (
@@ -0,0 +1,350 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import WorkflowApp from '../index'
const mockSetTriggerStatuses = vi.fn()
const mockSetInputs = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockDebouncedCancel = vi.fn()
const mockFetchRunDetail = vi.fn()
const mockInitialNodes = vi.fn()
const mockInitialEdges = vi.fn()
const mockGetWorkflowRunAndTraceUrl = vi.fn()
let appStoreState: {
appDetail?: {
id: string
mode: string
}
}
let workflowInitState: {
data: {
graph: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: { x: number, y: number, zoom: number }
}
features: Record<string, unknown>
} | null
isLoading: boolean
fileUploadConfigResponse: Record<string, unknown> | null
}
let appContextState: {
isLoadingCurrentWorkspace: boolean
currentWorkspace: {
id?: string
}
}
let appTriggersState: {
data?: {
data: Array<{
node_id: string
status: string
}>
}
}
let searchParamsValue: string | null = null
const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
getState: () => ({
setInputs: mockSetInputs,
setShowInputsPanel: mockSetShowInputsPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
debouncedSyncWorkflowDraft: {
cancel: mockDebouncedCancel,
},
}),
}
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatuses: mockSetTriggerStatuses,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => appContextState,
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null),
}),
}))
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => appTriggersState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({
useWorkflowInit: () => workflowInitState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({
useGetRunAndTraceUrl: () => ({
getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
}
})
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({
features,
children,
}: {
features: Record<string, unknown>
children: ReactNode
}) => (
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({
nodes,
edges,
children,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
children: ReactNode
}) => (
<div data-testid="workflow-default-context" data-nodes={JSON.stringify(nodes)} data-edges={JSON.stringify(edges)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({
children,
}: {
injectWorkflowStoreSliceFn: unknown
children: ReactNode
}) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
default: ({
nodes,
edges,
viewport,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: Record<string, unknown>
}) => (
<div
data-testid="workflow-app-main"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)}
/>
),
}))
describe('WorkflowApp', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
},
}
workflowInitState = {
data: {
graph: {
nodes: [{ id: 'raw-node' }],
edges: [{ id: 'raw-edge' }],
viewport: { x: 1, y: 2, zoom: 3 },
},
features: {
file_upload: {
enabled: true,
},
},
},
isLoading: false,
fileUploadConfigResponse: { enabled: true },
}
appContextState = {
isLoadingCurrentWorkspace: false,
currentWorkspace: { id: 'workspace-1' },
}
appTriggersState = {}
searchParamsValue = null
mockFetchRunDetail.mockResolvedValue({ inputs: null })
mockInitialNodes.mockReturnValue([{ id: 'node-1' }])
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
})
it('should render the loading shell while workflow data is still loading', () => {
workflowInitState = {
data: null,
isLoading: true,
fileUploadConfigResponse: null,
}
render(<WorkflowApp />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument()
})
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
appTriggersState = {
data: {
data: [
{ node_id: 'trigger-enabled', status: 'enabled' },
{ node_id: 'trigger-disabled', status: 'paused' },
],
},
}
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }]))
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }]))
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 }))
expect(screen.getByTestId('features-provider')).toBeInTheDocument()
expect(mockSetTriggerStatuses).toHaveBeenCalledWith({
'trigger-enabled': 'enabled',
'trigger-disabled': 'disabled',
})
})
it('should not sync trigger statuses when trigger data is unavailable', () => {
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
})
it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
})
const { unmount } = render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
expect(mockSetInputs).toHaveBeenCalledWith({
foo: 'bar',
count: 2,
flag: true,
obj: '{"nested":true}',
nil: '',
})
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
})
unmount()
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false })
expect(mockDebouncedCancel).toHaveBeenCalled()
})
it('should skip replay lookups when replayRunId is missing', () => {
render(<WorkflowApp />)
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should skip replay fetches when the resolved run url is empty', async () => {
searchParamsValue = 'run-1'
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '' })
render(<WorkflowApp />)
await waitFor(() => {
expect(mockGetWorkflowRunAndTraceUrl).toHaveBeenCalledWith('run-1')
})
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should stop replay recovery when workflow run inputs cannot be parsed', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{invalid-json}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to parse workflow run inputs',
expect.any(Error),
)
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should ignore replay inputs when they only contain sys variables', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","sys.user_id":"u-1"}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,90 @@
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from '../utils'
describe('workflow-app utils', () => {
it('should map trigger statuses to enabled and disabled states', () => {
expect(buildTriggerStatusMap([
{ node_id: 'node-1', status: 'enabled' },
{ node_id: 'node-2', status: 'disabled' },
{ node_id: 'node-3', status: 'paused' },
])).toEqual({
'node-1': 'enabled',
'node-2': 'disabled',
'node-3': 'disabled',
})
})
it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => {
expect(coerceReplayUserInputs({
'sys.query': 'hidden',
'query': 'hello',
'count': 3,
'enabled': true,
'nullable': null,
'metadata': { nested: true },
})).toEqual({
query: 'hello',
count: 3,
enabled: true,
nullable: '',
metadata: '{"nested":true}',
})
expect(coerceReplayUserInputs('invalid')).toBeNull()
expect(coerceReplayUserInputs(null)).toBeNull()
})
it('should build initial features with file-upload and feature fallbacks', () => {
const result = buildInitialFeatures({
file_upload: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
speech_to_text: { enabled: true },
text_to_speech: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
}, { enabled: true } as never)
expect(result).toMatchObject({
file: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
fileUploadConfig: { enabled: true },
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
})
})
})
@@ -0,0 +1,494 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowChildren from '../workflow-children'
type WorkflowStoreState = {
showFeaturesPanel: boolean
showImportDSLModal: boolean
setShowImportDSLModal: (show: boolean) => void
showOnboarding: boolean
setShowOnboarding: (show: boolean) => void
setHasSelectedStartNode: (selected: boolean) => void
setShouldAutoOpenStartNodeSelector: (open: boolean) => void
}
type TriggerPluginConfig = {
plugin_id: string
provider_name: string
provider_type: string
event_name: string
event_label: string
event_description: string
output_schema: Record<string, unknown>
paramSchemas: Array<Record<string, unknown>>
params: Record<string, unknown>
subscription_id: string
plugin_unique_identifier: string
is_team_authorization: boolean
meta?: Record<string, unknown>
}
const mockSetShowImportDSLModal = vi.fn()
const mockSetShowOnboarding = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleOnboardingClose = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleExportDSL = vi.fn()
const mockExportCheck = vi.fn()
const mockAutoGenerateWebhookUrl = vi.fn()
let workflowStoreState: WorkflowStoreState
let eventSubscription: ((value: { type: string, payload: { data: Array<Record<string, unknown>> } }) => void) | null = null
let lastGenerateNodeInput: Record<string, unknown> | null = null
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
setNodes: mockSetNodes,
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: typeof eventSubscription) => {
eventSubscription = callback
},
},
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl,
useDSL: () => ({
exportCheck: mockExportCheck,
handleExportDSL: mockHandleExportDSL,
}),
usePanelInteractions: () => ({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: Record<string, unknown>) => {
lastGenerateNodeInput = args
return {
newNode: {
id: 'new-node-id',
position: args.position,
data: args.data,
},
}
},
}
})
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Start]: {
defaultValue: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
},
[BlockEnum.TriggerPlugin]: {
defaultValue: {
title: 'Plugin title',
desc: 'Plugin description',
config: {
baseConfig: 'base',
},
},
},
},
}),
}))
vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({
useAutoOnboarding: () => ({
handleOnboardingClose: mockHandleOnboardingClose,
}),
}))
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency">plugin-dependency</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-header', () => ({
default: () => <div data-testid="workflow-header">workflow-header</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-panel', () => ({
default: () => <div data-testid="workflow-panel">workflow-panel</div>,
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('@/app/components/workflow/features', () => ({
default: () => <div data-testid="workflow-features">features</div>,
}))
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({
onCancel,
onBackup,
onImport,
}: {
onCancel: () => void
onBackup: () => void
onImport: () => void
}) => (
<div data-testid="update-dsl-modal">
<button type="button" onClick={onCancel}>cancel-import-dsl</button>
<button type="button" onClick={onBackup}>backup-dsl</button>
<button type="button" onClick={onImport}>import-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({
envList,
onConfirm,
onClose,
}: {
envList: Array<Record<string, unknown>>
onConfirm: () => void
onClose: () => void
}) => (
<div data-testid="dsl-export-confirm-modal" data-env-count={String(envList.length)}>
<button type="button" onClick={onConfirm}>confirm-export-dsl</button>
<button type="button" onClick={onClose}>close-export-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () => ({
default: ({
onClose,
onSelectStartNode,
}: {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void
}) => (
<div data-testid="workflow-onboarding-modal">
<button type="button" onClick={onClose}>close-onboarding</button>
<button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}>select-start-node</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.Start, {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: { image: true, custom: 'config' },
extra: 'field',
} as never)}
>
select-start-node-with-config
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
output_schema: { output: true },
paramSchemas: [{ name: 'api_key' }],
params: { token: 'abc' },
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
})}
>
select-trigger-plugin
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id-2',
provider_name: 'provider-name-2',
provider_type: 'tool',
event_name: 'event-name-2',
event_label: '',
event_description: '',
output_schema: {},
paramSchemas: undefined,
params: {},
subscription_id: 'subscription-id-2',
plugin_unique_identifier: 'plugin-unique-2',
is_team_authorization: false,
} as never)}
>
select-trigger-plugin-fallback
</button>
</div>
),
}))
describe('WorkflowChildren', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
showFeaturesPanel: false,
showImportDSLModal: false,
setShowImportDSLModal: mockSetShowImportDSLModal,
showOnboarding: false,
setShowOnboarding: mockSetShowOnboarding,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
}
eventSubscription = null
lastGenerateNodeInput = null
mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => {
callback?.onSuccess?.()
})
})
it('should render feature panel, import modal actions, and default workflow chrome', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showFeaturesPanel: true,
showImportDSLModal: true,
}
render(<WorkflowChildren />)
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
expect(await screen.findByTestId('workflow-features')).toBeInTheDocument()
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /cancel-import-dsl/i }))
await user.click(screen.getByRole('button', { name: /backup-dsl/i }))
await user.click(screen.getByRole('button', { name: /^import-dsl$/i }))
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
expect(mockExportCheck).toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
})
it('should react to DSL export check events by showing the confirm modal and closing it', async () => {
const user = userEvent.setup()
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: DSL_EXPORT_CHECK,
payload: {
data: [{ id: 'env-1' }, { id: 'env-2' }],
},
})
})
expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2')
await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i }))
await user.click(screen.getByRole('button', { name: /close-export-dsl/i }))
expect(mockHandleExportDSL).toHaveBeenCalled()
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should ignore unrelated workflow events when listening for DSL export checks', async () => {
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: 'UNRELATED_EVENT',
payload: {
data: [{ id: 'env-1' }],
},
})
})
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should close onboarding through the onboarding hook callback', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
expect(await screen.findByTestId('workflow-onboarding-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /close-onboarding/i }))
expect(mockHandleOnboardingClose).toHaveBeenCalled()
})
it('should create a start node, sync draft, and auto-generate webhook url after selecting a start node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-start-node$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
})
expect(mockSetNodes).toHaveBeenCalledWith([expect.objectContaining({ id: 'new-node-id' })])
expect(mockSetEdges).toHaveBeenCalledWith([])
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.any(Object))
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('new-node-id')
})
it('should merge non-trigger start node config directly into the default node data', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-start-node-with-config/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: {
image: true,
custom: 'config',
},
extra: 'field',
},
})
})
it('should merge trigger plugin defaults and config before creating the node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
plugin_id: 'plugin-id',
provider_id: 'provider-name',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
title: 'Event Label',
desc: 'Event Description',
output_schema: { output: true },
parameters_schema: [{ name: 'api_key' }],
config: {
baseConfig: 'base',
token: 'abc',
},
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
},
})
})
it('should fall back to plugin default title and description when trigger labels are missing', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-trigger-plugin-fallback/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Plugin title',
desc: 'Plugin description',
parameters_schema: [],
config: {
baseConfig: 'base',
},
},
})
})
})
@@ -0,0 +1,277 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import WorkflowMain from '../workflow-main'
const mockSetFeatures = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const hookFns = {
doSyncWorkflowDraft: vi.fn(),
syncWorkflowDraftWhenPageClose: vi.fn(),
handleRefreshWorkflowDraft: vi.fn(),
handleBackupDraft: vi.fn(),
handleLoadBackupDraft: vi.fn(),
handleRestoreFromPublishedWorkflow: vi.fn(),
handleRun: vi.fn(),
handleStopRun: vi.fn(),
handleStartWorkflowRun: vi.fn(),
handleWorkflowStartRunInChatflow: vi.fn(),
handleWorkflowStartRunInWorkflow: vi.fn(),
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
getWorkflowRunAndTraceUrl: vi.fn(),
exportCheck: vi.fn(),
handleExportDSL: vi.fn(),
fetchInspectVars: vi.fn(),
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedContextProps: Record<string, unknown> | null = null
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
hooksStore?: Record<string, unknown>
children?: ReactNode
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setConversationVariables: mockSetConversationVariables,
setEnvironmentVariables: mockSetEnvironmentVariables,
}),
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
nodes,
edges,
viewport,
onWorkflowDataUpdate,
hooksStore,
children,
}: MockWorkflowWithInnerContextProps) => {
capturedContextProps = {
nodes,
edges,
viewport,
hooksStore,
}
return (
<div data-testid="workflow-inner-context">
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
features: { file: { enabled: true } },
conversation_variables: [{ id: 'conversation-1' }],
environment_variables: [{ id: 'env-1' }],
})}
>
update-workflow-data
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
conversation_variables: [{ id: 'conversation-only' }],
})}
>
update-conversation-only
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({})}
>
update-empty-payload
</button>
{children}
</div>
)
},
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }),
useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }),
useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }),
useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }),
useInspectVarsCrud: () => ({
hasNodeInspectVars: hookFns.hasNodeInspectVars,
hasSetInspectVar: hookFns.hasSetInspectVar,
fetchInspectVarValue: hookFns.fetchInspectVarValue,
editInspectVarValue: hookFns.editInspectVarValue,
renameInspectVarName: hookFns.renameInspectVarName,
appendNodeInspectVars: hookFns.appendNodeInspectVars,
deleteInspectVar: hookFns.deleteInspectVar,
deleteNodeInspectorVars: hookFns.deleteNodeInspectorVars,
deleteAllInspectorVars: hookFns.deleteAllInspectorVars,
isInspectVarEdited: hookFns.isInspectVarEdited,
resetToLastRunVar: hookFns.resetToLastRunVar,
invalidateSysVarValues: hookFns.invalidateSysVarValues,
resetConversationVar: hookFns.resetConversationVar,
invalidateConversationVarValues: hookFns.invalidateConversationVarValues,
}),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
}),
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: hookFns.fetchInspectVars,
}),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
}),
useWorkflowStartRun: () => ({
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
}),
}))
vi.mock('../workflow-children', () => ({
default: () => <div data-testid="workflow-children">workflow-children</div>,
}))
describe('WorkflowMain', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedContextProps = null
})
it('should render the inner workflow context with children and forwarded graph props', () => {
const nodes = [{ id: 'node-1' }]
const edges = [{ id: 'edge-1' }]
const viewport = { x: 1, y: 2, zoom: 1.5 }
render(
<WorkflowMain
nodes={nodes as never}
edges={edges as never}
viewport={viewport}
/>,
)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-children')).toBeInTheDocument()
expect(capturedContextProps).toMatchObject({
nodes,
edges,
viewport,
})
})
it('should update features and workflow variables when workflow data changes', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } })
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }])
})
it('should only update the workflow store slices present in the payload', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }])
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should ignore empty workflow data updates', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i }))
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetConversationVariables).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should expose the composed workflow action hooks through hooksStore', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
expect(capturedContextProps?.hooksStore).toMatchObject({
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft,
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData: { nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } },
getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl,
exportCheck: hookFns.exportCheck,
handleExportDSL: hookFns.handleExportDSL,
fetchInspectVars: hookFns.fetchInspectVars,
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
})
})
})
@@ -0,0 +1,214 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import WorkflowPanel from '../workflow-panel'
type AppStoreState = {
appDetail?: {
id?: string
workflow?: {
id?: string
}
}
currentLogItem?: { id: string }
setCurrentLogItem: (item?: { id: string }) => void
showMessageLogModal: boolean
setShowMessageLogModal: (show: boolean) => void
currentLogModalActiveTab?: string
}
type WorkflowStoreState = {
historyWorkflowData?: Record<string, unknown>
showDebugAndPreviewPanel: boolean
showChatVariablePanel: boolean
showGlobalVariablePanel: boolean
}
const mockUseIsChatMode = vi.fn()
const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
let appStoreState: AppStoreState
let workflowStoreState: WorkflowStoreState
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({
components,
versionHistoryPanelProps,
}: {
components?: {
left?: ReactNode
right?: ReactNode
}
versionHistoryPanelProps?: {
getVersionListUrl: string
deleteVersionUrl: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl: (versionId: string) => string
latestVersionId?: string
}
}) => (
<div
data-testid="panel"
data-version-list-url={versionHistoryPanelProps?.getVersionListUrl ?? ''}
data-delete-version-url={versionHistoryPanelProps?.deleteVersionUrl('version-1') ?? ''}
data-restore-version-url={versionHistoryPanelProps?.restoreVersionUrl('version-1') ?? ''}
data-update-version-url={versionHistoryPanelProps?.updateVersionUrl('version-1') ?? ''}
data-latest-version-id={versionHistoryPanelProps?.latestVersionId ?? ''}
>
<div data-testid="panel-left">{components?.left}</div>
<div data-testid="panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => {
const LazyComp = React.lazy(loader)
return function DynamicWrapper(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComp, props),
)
}
},
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({
currentLogItem,
defaultTab,
onCancel,
}: {
currentLogItem?: { id: string }
defaultTab?: string
onCancel: () => void
}) => (
<div data-testid="message-log-modal" data-current-log-id={currentLogItem?.id ?? ''} data-default-tab={defaultTab ?? ''}>
<button type="button" onClick={onCancel}>close-message-log</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/record', () => ({
default: () => <div data-testid="record-panel">record</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-record', () => ({
default: () => <div data-testid="chat-record-panel">chat-record</div>,
}))
vi.mock('@/app/components/workflow/panel/debug-and-preview', () => ({
default: () => <div data-testid="debug-and-preview-panel">debug</div>,
}))
vi.mock('@/app/components/workflow/panel/workflow-preview', () => ({
default: () => <div data-testid="workflow-preview-panel">preview</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel', () => ({
default: () => <div data-testid="chat-variable-panel">chat-variable</div>,
}))
vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({
default: () => <div data-testid="global-variable-panel">global-variable</div>,
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
describe('WorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-123',
workflow: {
id: 'workflow-version-id',
},
},
currentLogItem: { id: 'log-1' },
setCurrentLogItem: mockSetCurrentLogItem,
showMessageLogModal: false,
setShowMessageLogModal: mockSetShowMessageLogModal,
currentLogModalActiveTab: 'detail',
}
workflowStoreState = {
historyWorkflowData: undefined,
showDebugAndPreviewPanel: false,
showChatVariablePanel: false,
showGlobalVariablePanel: false,
}
mockUseIsChatMode.mockReturnValue(false)
})
it('should configure workflow version history urls and latest version id for the panel shell', async () => {
render(<WorkflowPanel />)
const panel = await screen.findByTestId('panel')
expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows')
expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore')
expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id')
})
it('should render and close the message log modal from the left panel slot', async () => {
const user = userEvent.setup()
appStoreState = {
...appStoreState,
showMessageLogModal: true,
}
render(<WorkflowPanel />)
expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1')
expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail')
await user.click(screen.getByRole('button', { name: /close-message-log/i }))
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
})
it('should switch right-side workflow panels based on chat mode and workflow state', async () => {
workflowStoreState = {
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showChatVariablePanel: true,
showGlobalVariablePanel: true,
}
mockUseIsChatMode.mockReturnValue(true)
const { unmount } = render(<WorkflowPanel />)
expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument()
expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('chat-variable-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-preview-panel')).not.toBeInTheDocument()
unmount()
mockUseIsChatMode.mockReturnValue(false)
render(<WorkflowPanel />)
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('chat-record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('debug-and-preview-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('chat-variable-panel')).not.toBeInTheDocument()
})
})
@@ -149,6 +149,7 @@ const createProviderContext = ({
const renderWithToast = (ui: ReactElement) => {
return render(
// eslint-disable-next-line react/no-context-provider
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{ui}
</ToastContext.Provider>,
@@ -445,6 +446,27 @@ describe('FeaturesTrigger', () => {
})
})
it('should skip success side effects when publish mutation returns no workflow version', async () => {
// Arrange
const user = userEvent.setup()
mockPublishWorkflow.mockResolvedValue(null)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
expect(mockNotify).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled()
expect(mockInvalidateAppTriggers).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled()
expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled()
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()
@@ -0,0 +1,18 @@
import * as hooks from '../index'
describe('workflow-app hooks index', () => {
it('should re-export workflow-app hooks', () => {
expect(hooks.useAvailableNodesMetaData).toBeTypeOf('function')
expect(hooks.useConfigsMap).toBeTypeOf('function')
expect(hooks.useDSL).toBeTypeOf('function')
expect(hooks.useGetRunAndTraceUrl).toBeTypeOf('function')
expect(hooks.useInspectVarsCrud).toBeTypeOf('function')
expect(hooks.useIsChatMode).toBeTypeOf('function')
expect(hooks.useNodesSyncDraft).toBeTypeOf('function')
expect(hooks.useWorkflowInit).toBeTypeOf('function')
expect(hooks.useWorkflowRefreshDraft).toBeTypeOf('function')
expect(hooks.useWorkflowRun).toBeTypeOf('function')
expect(hooks.useWorkflowStartRun).toBeTypeOf('function')
expect(hooks.useWorkflowTemplate).toBeTypeOf('function')
})
})
@@ -0,0 +1,206 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { useDSL } from '../use-DSL'
const mockNotify = vi.fn()
const mockEmit = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockExportAppConfig = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
let appStoreState: {
appDetail?: {
id: string
name: string
}
}
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
vi.mock('@/service/apps', () => ({
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
const createDeferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useDSL', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
name: 'Workflow App',
},
}
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
})
it('should export workflow dsl and download the yaml blob when no secret env is present', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
workflowID: undefined,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
data: expect.any(Blob),
fileName: 'Workflow App.yml',
}))
})
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true, 'workflow-1')
})
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: true,
workflowID: 'workflow-1',
})
})
it('should emit DSL_EXPORT_CHECK when secret environment variables exist', async () => {
const secretVars = [{ id: 'env-1', value_type: 'secret', value: 'secret-token' }]
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: DSL_EXPORT_CHECK,
payload: {
data: secretVars,
},
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should return early when app detail is unavailable', async () => {
appStoreState = {}
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
await result.current.handleExportDSL()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockExportAppConfig).not.toHaveBeenCalled()
expect(mockEmit).not.toHaveBeenCalled()
})
it('should notify when export fails', async () => {
mockExportAppConfig.mockRejectedValue(new Error('export failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
})
it('should notify when exportCheck cannot load the workflow draft', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('draft fetch failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should ignore repeated export attempts while an export is already in progress', async () => {
const deferred = createDeferred<{ data: string }>()
mockExportAppConfig.mockReturnValue(deferred.promise)
const { result } = renderHook(() => useDSL())
let firstExportPromise!: Promise<void>
act(() => {
firstExportPromise = result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
})
act(() => {
void result.current.handleExportDSL()
})
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
await act(async () => {
deferred.resolve({ data: 'yaml-content' })
await firstExportPromise
})
})
})
@@ -0,0 +1,118 @@
import { act, renderHook } from '@testing-library/react'
import { useAutoOnboarding } from '../use-auto-onboarding'
const mockGetNodes = vi.fn()
const mockWorkflowStore = {
getState: vi.fn(),
}
const mockSetShowOnboarding = vi.fn()
const mockSetHasShownOnboarding = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useAutoOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockGetNodes.mockReturnValue([])
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: false,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('should open onboarding after the delayed empty-canvas check on mount', () => {
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
})
it('should skip auto onboarding when it is already visible or the workflow is not initial', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: true,
hasShownOnboarding: false,
notInitialWorkflow: true,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).not.toHaveBeenCalled()
expect(mockSetHasShownOnboarding).not.toHaveBeenCalled()
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and reset selected start node state when one was chosen', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: true,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: true,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and disable auto-open when no start node was selected', () => {
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
const mockUseIsChatMode = vi.fn()
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `/docs${path}`,
}))
describe('useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should include chat-specific nodes and make the start node undeletable in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(true)
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator])
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/')
})
it('should include workflow-specific trigger and end nodes outside chat mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(false)
expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.Start]?.defaultValue).toMatchObject({
type: BlockEnum.Start,
title: 'workflow.blocks.start',
})
})
})
@@ -0,0 +1,40 @@
import { renderHook } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { useConfigsMap } from '../use-configs-map'
const mockUseFeatures = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { appId: string }) => T) => selector({ appId: 'app-1' }),
}))
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => selector({
features: {
file: {
enabled: true,
number_limits: 3,
},
},
}))
})
it('should map workflow app id and feature file settings into inspect-var configs', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current).toEqual({
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {
enabled: true,
number_limits: 3,
},
})
})
})
@@ -0,0 +1,28 @@
import { renderHook } from '@testing-library/react'
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
const mockWorkflowStore = {
getState: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useGetRunAndTraceUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.getState.mockReturnValue({
appId: 'app-123',
})
})
it('should build workflow run and trace urls from the current app id', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({
runUrl: '/apps/app-123/workflow-runs/run-1',
traceUrl: '/apps/app-123/workflow-runs/run-1/node-executions',
})
})
})
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
const mockUseInspectVarsCrudCommon = vi.fn()
const mockUseConfigsMap = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: unknown[]) => mockUseInspectVarsCrudCommon(...args),
}))
vi.mock('@/app/components/workflow-app/hooks/use-configs-map', () => ({
useConfigsMap: () => mockUseConfigsMap(),
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfigsMap.mockReturnValue({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
mockUseInspectVarsCrudCommon.mockReturnValue({
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
deleteInspectVar: vi.fn(),
})
})
it('should call the shared inspect vars hook with workflow-app configs and return its api', () => {
const { result } = renderHook(() => useInspectVarsCrud())
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
expect(result.current).toEqual({
fetchInspectVarValue: expect.any(Function),
editInspectVarValue: expect.any(Function),
deleteInspectVar: expect.any(Function),
})
})
})
@@ -4,42 +4,57 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
const mockPostWithKeepalive = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockGetNodesReadOnly = vi.fn()
let reactFlowState: {
getNodes: typeof mockGetNodes
edges: Array<Record<string, unknown>>
transform: [number, number, number]
}
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
syncWorkflowDraftHash: string | null
environmentVariables: Array<Record<string, unknown>>
conversationVariables: Array<Record<string, unknown>>
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
}
let featuresState: {
features: {
opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] }
suggested: Record<string, unknown>
text2speech: Record<string, unknown>
speech2text: Record<string, unknown>
citation: Record<string, unknown>
moderation: Record<string, unknown>
file: Record<string, unknown>
}
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => ({ getNodes: mockGetNodes, edges: [], transform: [0, 0, 1] }) }),
useStoreApi: () => ({ getState: () => reactFlowState }),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}),
getState: () => featuresState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: () => false }),
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
}))
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
@@ -55,7 +70,7 @@ vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
}))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: vi.fn() }))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
const mockHandleRefreshWorkflowDraft = vi.fn()
@@ -66,6 +81,32 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({
describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState = {
getNodes: mockGetNodes,
edges: [],
transform: [0, 0, 1],
}
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
}
featuresState = {
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }])
mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 })
})
@@ -122,4 +163,102 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
}),
}))
})
it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => {
reactFlowState = {
...reactFlowState,
edges: [
{ id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } },
{ id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } },
],
transform: [10, 20, 1.5],
}
mockGetNodes.mockReturnValue([
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', _tempField: 'drop', label: 'Start' } },
{ id: 'temp-node', position: { x: 1, y: 1 }, data: { type: 'answer', _isTempNode: true } },
])
workflowStoreState = {
...workflowStoreState,
syncWorkflowDraftHash: 'latest-hash',
environmentVariables: [{ id: 'env-1', value: 'env' }],
conversationVariables: [{ id: 'conversation-1', value: 'conversation' }],
}
featuresState = {
features: {
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: false },
file: { enabled: true },
},
}
const callbacks = {
onSuccess: vi.fn(),
onError: vi.fn(),
onSettled: vi.fn(),
}
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, callbacks)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: '/apps/app-1/workflows/draft',
params: {
graph: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }],
edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }],
viewport: { x: 10, y: 20, zoom: 1.5 },
},
features: {
opening_statement: 'Hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-1', value: 'env' }],
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
hash: 'latest-hash',
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1)
expect(callbacks.onSuccess).toHaveBeenCalled()
expect(callbacks.onError).not.toHaveBeenCalled()
expect(callbacks.onSettled).toHaveBeenCalled()
})
it('should post workflow draft with keepalive when the page closes', () => {
reactFlowState = {
...reactFlowState,
transform: [1, 2, 3],
}
workflowStoreState = {
...workflowStoreState,
environmentVariables: [{ id: 'env-1' }],
conversationVariables: [{ id: 'conversation-1' }],
}
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({
graph: expect.objectContaining({
viewport: { x: 1, y: 2, zoom: 3 },
}),
hash: 'hash-123',
}))
})
})
@@ -1,5 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useWorkflowInit } from '../use-workflow-init'
@@ -11,6 +12,21 @@ const mockSetLastPublishedHasUserInput = vi.fn()
const mockSetFileUploadConfig = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockWorkflowStoreGetState = vi.fn()
const mockFetchNodesDefaultConfigs = vi.fn()
const mockFetchPublishedWorkflow = vi.fn()
let appStoreState: {
appDetail: {
id: string
name: string
mode: string
}
}
let workflowConfigState: {
data: Record<string, unknown> | null
isLoading: boolean
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T): T =>
@@ -22,8 +38,8 @@ vi.mock('@/app/components/workflow/store', () => ({
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: { appDetail: { id: string, name: string, mode: string } }) => T): T =>
selector({ appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' } }),
useStore: <T>(selector: (state: typeof appStoreState) => T): T =>
selector(appStoreState),
}))
vi.mock('../use-workflow-template', () => ({
@@ -31,7 +47,11 @@ vi.mock('../use-workflow-template', () => ({
}))
vi.mock('@/service/use-workflow', () => ({
useWorkflowConfig: () => ({ data: null, isLoading: false }),
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
if (workflowConfigState.data)
onSuccess(workflowConfigState.data)
return workflowConfigState
},
}))
const mockFetchWorkflowDraft = vi.fn()
@@ -40,8 +60,8 @@ const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
fetchNodesDefaultConfigs: () => Promise.resolve([]),
fetchPublishedWorkflow: () => Promise.resolve({ created_at: 0, graph: { nodes: [], edges: [] } }),
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
}))
const notExistError = () => ({
@@ -68,6 +88,10 @@ const draftResponse = {
describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' },
}
workflowConfigState = { data: null, isLoading: false }
mockWorkflowStoreGetState.mockReturnValue({
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setToolPublished: mockSetToolPublished,
@@ -75,6 +99,8 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
setFileUploadConfig: mockSetFileUploadConfig,
})
mockFetchNodesDefaultConfigs.mockResolvedValue([])
mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } })
mockFetchWorkflowDraft
.mockRejectedValueOnce(notExistError())
.mockResolvedValueOnce(draftResponse)
@@ -104,4 +130,77 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
expect(order).toContain('hash:new-hash')
expect(order.indexOf('hash:new-hash')).toBeLessThan(order.indexOf('fetch:2'))
})
it('should hydrate draft state, preload defaults, and derive published workflow metadata on success', async () => {
workflowConfigState = {
data: { enabled: true, sizeLimit: 20 },
isLoading: false,
}
mockFetchWorkflowDraft.mockReset().mockResolvedValue({
...draftResponse,
updated_at: 9,
tool_published: true,
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
mockFetchNodesDefaultConfigs.mockResolvedValue([
{ type: 'start', config: { title: 'Start Config' } },
{ type: 'start', config: { title: 'Ignored Duplicate' } },
])
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 99,
graph: {
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
edges: [{ source: 'start', target: 'end' }],
},
})
const { result } = renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(result.current.data?.hash).toBe('server-hash')
})
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
envSecrets: { 'env-secret': 'top-secret' },
environmentVariables: [
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversationVariables: [{ id: 'conversation-1' }],
isWorkflowDataLoaded: true,
}))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
start: { title: 'Start Config' },
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(9)
expect(mockSetToolPublished).toHaveBeenCalledWith(true)
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockSetFileUploadConfig).toHaveBeenCalledWith({ enabled: true, sizeLimit: 20 })
expect(result.current.fileUploadConfigResponse).toEqual({ enabled: true, sizeLimit: 20 })
expect(result.current.isLoading).toBe(false)
})
it('should fall back to no published user input when preload requests fail', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
})
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
@@ -1,24 +1,32 @@
import { act, renderHook } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
const mockHandleUpdateWorkflowCanvas = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetIsWorkflowDataLoaded = vi.fn()
const mockCancel = vi.fn()
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
debouncedSyncWorkflowDraft?: { cancel: () => void }
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setIsSyncingWorkflowDraft: typeof mockSetIsSyncingWorkflowDraft
setEnvironmentVariables: typeof mockSetEnvironmentVariables
setEnvSecrets: typeof mockSetEnvSecrets
setConversationVariables: typeof mockSetConversationVariables
setIsWorkflowDataLoaded: typeof mockSetIsWorkflowDataLoaded
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: vi.fn(),
setEnvironmentVariables: vi.fn(),
setEnvSecrets: vi.fn(),
setConversationVariables: vi.fn(),
setIsWorkflowDataLoaded: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
@@ -41,6 +49,17 @@ const draftResponse = {
describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setConversationVariables: mockSetConversationVariables,
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
}
mockFetchWorkflowDraft.mockResolvedValue(draftResponse)
})
@@ -75,6 +94,67 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
await act(async () => {
result.current.handleRefreshWorkflowDraft(true)
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
})
})
it('should cancel pending draft sync, use fallback viewport, and persist masked secrets', async () => {
workflowStoreState = {
...workflowStoreState,
debouncedSyncWorkflowDraft: { cancel: mockCancel },
}
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'server-hash',
graph: {
nodes: [{ id: 'n1' }],
edges: [],
},
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockCancel).toHaveBeenCalled()
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'n1' }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
'env-secret': 'top-secret',
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
])
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
})
})
it('should restore loaded state when refresh fails after workflow data was already loaded', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed'))
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(1, false)
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(2, true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
})
})
@@ -0,0 +1,451 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks'
const {
mockSseGet,
mockResetMsgId,
} = vi.hoisted(() => ({
mockSseGet: vi.fn(),
mockResetMsgId: vi.fn(),
}))
vi.mock('@/service/base', () => ({
sseGet: mockSseGet,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
resetMsgId: mockResetMsgId,
}),
},
}))
const createHandlers = () => ({
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
})
const createUserCallbacks = () => ({
onWorkflowStarted: vi.fn(),
onWorkflowFinished: vi.fn(),
onNodeStarted: vi.fn(),
onNodeFinished: vi.fn(),
onIterationStart: vi.fn(),
onIterationNext: vi.fn(),
onIterationFinish: vi.fn(),
onLoopStart: vi.fn(),
onLoopNext: vi.fn(),
onLoopFinish: vi.fn(),
onNodeRetry: vi.fn(),
onAgentLog: vi.fn(),
onError: vi.fn(),
onWorkflowPaused: vi.fn(),
onHumanInputRequired: vi.fn(),
onHumanInputFormFilled: vi.fn(),
onHumanInputFormTimeout: vi.fn(),
onCompleted: vi.fn(),
})
describe('useWorkflowRun callbacks helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create base callbacks that wrap workflow events, errors, pause continuation, and lazy tts playback', () => {
const handlers = createHandlers()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const userOnWorkflowFinished = vi.fn()
const userOnError = vi.fn()
const userOnWorkflowPaused = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => player)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: {
onWorkflowFinished: userOnWorkflowFinished,
onError: userOnError,
onWorkflowPaused: userOnWorkflowPaused,
},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
expect(userOnWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userOnError).toHaveBeenCalled()
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
expect(getOrCreatePlayer).toHaveBeenCalled()
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userOnWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
})
it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => {
const handlers = createHandlers()
const restOnNodeStarted = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {
onNodeStarted: restOnNodeStarted,
},
baseSseOptions,
player,
setAbortController,
})
const controller = new AbortController()
finalCallbacks.getAbortController?.(controller)
expect(setAbortController).toHaveBeenCalledWith(controller)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
expect(restOnNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled()
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-2')
})
it('should route base workflow events through handlers, user callbacks, and pause continuation with the same callback object', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 640,
clientHeight: 360,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: userCallbacks,
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
callbacks.onWorkflowStarted?.({ workflow_run_id: 'run-1' } as never)
callbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
callbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
callbacks.onIterationStart?.({ node_id: 'node-1' } as never)
callbacks.onIterationNext?.({ node_id: 'node-1' } as never)
callbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
callbacks.onLoopStart?.({ node_id: 'node-1' } as never)
callbacks.onLoopNext?.({ node_id: 'node-1' } as never)
callbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
callbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
callbacks.onAgentLog?.({ node_id: 'node-1' } as never)
callbacks.onTextChunk?.({ data: 'chunk' } as never)
callbacks.onTextReplace?.({ text: 'replacement' } as never)
callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
await callbacks.onCompleted?.(false, '')
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500')
expect(handlers.handleWorkflowStarted).toHaveBeenCalled()
expect(userCallbacks.onWorkflowStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopFinished).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(handlers.handleWorkflowAgentLog).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500')
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
it('should skip base debug-only side effects and audio playback when debug mode is off or audio is empty', () => {
const handlers = createHandlers()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => null)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
callbacks.onTTSChunk?.('message-1', '')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
expect(fetchInspectVars).not.toHaveBeenCalled()
expect(invalidAllLastRun).not.toHaveBeenCalled()
expect(getOrCreatePlayer).toHaveBeenCalledTimes(1)
expect(mockResetMsgId).not.toHaveBeenCalled()
})
it('should route final workflow events through handlers and continue paused runs with final callbacks', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const invalidateRunHistory = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: userCallbacks,
restCallback: {},
baseSseOptions,
player,
setAbortController,
})
finalCallbacks.getAbortController?.(new AbortController())
finalCallbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never)
finalCallbacks.onTextChunk?.({ data: 'chunk' } as never)
finalCallbacks.onTextReplace?.({ text: 'replacement' } as never)
finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
finalCallbacks.onTTSEnd?.('message-2', 'audio-finished')
await finalCallbacks.onCompleted?.(true, 'done')
finalCallbacks.onError?.({ error: 'failed' } as never, '500')
expect(setAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed' }, '500')
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
})
@@ -0,0 +1,431 @@
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import {
applyRunningStateForMode,
applyStoppedState,
buildListeningTriggerNodeIds,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
createFailedWorkflowState,
createRunningWorkflowState,
createStoppedWorkflowState,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from '../use-workflow-run-utils'
const {
mockPost,
mockHandleStream,
mockToastError,
} = vi.hoisted(() => ({
mockPost: vi.fn(),
mockHandleStream: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/service/base', () => ({
post: mockPost,
handleStream: mockHandleStream,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
const createListeningActions = () => ({
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should resolve run history urls and run endpoints for workflow modes', () => {
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs')
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all')
})
it('should build request bodies and validation errors for trigger runs', () => {
expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' })
expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] })
expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } })
expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('')
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('')
})
it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('')
expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all')
consoleErrorSpy.mockRestore()
})
it('should configure listening state for trigger and non-trigger modes', () => {
const triggerActions = createListeningActions()
applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })
expect(triggerActions.setIsListening).toHaveBeenCalledWith(true)
expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
const normalActions = createListeningActions()
applyRunningStateForMode(normalActions, TriggerType.UserInput)
expect(normalActions.setIsListening).toHaveBeenCalledWith(false)
expect(normalActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(normalActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(normalActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
})
it('should clear listening state, stop state, and remove debug controllers', () => {
const listeningActions = createListeningActions()
clearListeningState(listeningActions)
expect(listeningActions.setIsListening).toHaveBeenCalledWith(false)
expect(listeningActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(listeningActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
const stoppedActions = createListeningActions()
applyStoppedState(stoppedActions)
expect(stoppedActions.setWorkflowRunningData).toHaveBeenCalledWith(createStoppedWorkflowState())
expect(stoppedActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
const controllerTarget = {
__webhookDebugAbortController: { abort: vi.fn() },
__pluginDebugAbortController: { abort: vi.fn() },
__scheduleDebugAbortController: { abort: vi.fn() },
__allTriggersDebugAbortController: { abort: vi.fn() },
}
clearWindowDebugControllers(controllerTarget)
expect(controllerTarget).toEqual({})
})
it('should derive listening node ids, tts config, and published workflow mappings', () => {
expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1'])
expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2'])
expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({
ttsUrl: '/text-to-audio',
ttsIsPublic: true,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/explore/installed/app-1')).toEqual({
ttsUrl: '/installed-apps/app-1/text-to-audio',
ttsIsPublic: false,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/apps/app-1/workflow')).toEqual({
ttsUrl: '/apps/app-1/text-to-audio',
ttsIsPublic: false,
})
const publishedWorkflow = {
graph: {
nodes: [{ id: 'node-1', selected: true, data: { selected: true, title: 'Start' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
} as never
expect(normalizePublishedWorkflowNodes(publishedWorkflow)).toEqual([
{ id: 'node-1', selected: false, data: { selected: false, title: 'Start' } },
])
expect(mapPublishedWorkflowFeatures(publishedWorkflow)).toMatchObject({
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
file: { enabled: true },
})
})
it('should handle trigger debug null and invalid json responses as request failures', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockResolvedValueOnce(null)
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Webhook debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce(new Response('{invalid-json}', {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Schedule,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'schedule-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'handleRun: schedule debug response parse error',
expect.any(Error),
)
expect(mockToastError).toHaveBeenCalledWith('Schedule debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should handle trigger debug json failures and stream responses', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(setAbortController).toHaveBeenCalledTimes(1)
expect(mockToastError).toHaveBeenCalledWith('Webhook failed')
expect(clearAbortController).toHaveBeenCalled()
expect(clearListeningStateSpy).toHaveBeenCalled()
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed'))
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
})
it('should retry waiting trigger debug responses until a stream is returned', async () => {
vi.useFakeTimers()
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), {
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
const runPromise = runTriggerDebug({
debugType: TriggerType.All,
url: '/apps/app-1/workflows/draft/trigger/run-all',
requestBody: { node_ids: ['trigger-1'] },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
await vi.advanceTimersByTimeAsync(1)
await runPromise
expect(mockPost).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('should stop trigger debug processing when the controller aborts before handling the response', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController: (controller) => {
controller?.abort()
},
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockHandleStream).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
expect(clearAbortController).not.toHaveBeenCalled()
expect(clearListeningStateSpy).not.toHaveBeenCalled()
expect(setWorkflowRunningData).not.toHaveBeenCalled()
})
it('should handle Response and non-Response trigger debug exceptions correctly', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Plugin failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Plugin failed'))
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
mockPost.mockRejectedValueOnce(new Error('network failed'))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
})
it('should expose the canonical workflow state factories', () => {
expect(createRunningWorkflowState().result.status).toBe(WorkflowRunningStatus.Running)
expect(createStoppedWorkflowState().result.status).toBe(WorkflowRunningStatus.Stopped)
expect(createFailedWorkflowState('failed').result.status).toBe(WorkflowRunningStatus.Failed)
})
})
@@ -0,0 +1,592 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useWorkflowRun } from '../use-workflow-run'
type DebugAbortControllerRef = {
abort: () => void
}
type DebugControllerWindow = Window & {
__webhookDebugAbortController?: DebugAbortControllerRef
__pluginDebugAbortController?: DebugAbortControllerRef
__scheduleDebugAbortController?: DebugAbortControllerRef
__allTriggersDebugAbortController?: DebugAbortControllerRef
}
type WorkflowStoreState = {
backupDraft?: unknown
environmentVariables?: unknown
setBackupDraft?: (value: unknown) => void
setEnvironmentVariables?: (value: unknown) => void
setWorkflowRunningData?: (value: unknown) => void
setIsListening?: (value: boolean) => void
setShowVariableInspectPanel?: (value: boolean) => void
setListeningTriggerType?: (value: unknown) => void
setListeningTriggerNodeIds?: (value: string[]) => void
setListeningTriggerIsAll?: (value: boolean) => void
setListeningTriggerNodeId?: (value: string | null) => void
}
const mocks = vi.hoisted(() => {
const appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
name: 'Workflow App',
},
}
const reactFlowStoreState = {
edges: [{ id: 'edge-1' }],
getNodes: vi.fn(),
setNodes: vi.fn(),
}
const workflowStoreState: WorkflowStoreState = {}
const workflowStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(workflowStoreState, partial)
})
const featuresStoreState = {
features: {
file: {
enabled: true,
},
},
}
const featuresStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(featuresStoreState, partial)
})
return {
appStoreState,
reactFlowStoreState,
workflowStoreState,
workflowStoreSetState,
featuresStoreState,
featuresStoreSetState,
mockGetViewport: vi.fn(),
mockDoSyncWorkflowDraft: vi.fn(),
mockHandleUpdateWorkflowCanvas: vi.fn(),
mockFetchInspectVars: vi.fn(),
mockInvalidateAllLastRun: vi.fn(),
mockInvalidateRunHistory: vi.fn(),
mockSsePost: vi.fn(),
mockSseGet: vi.fn(),
mockHandleStream: vi.fn(),
mockPost: vi.fn(),
mockStopWorkflowRun: vi.fn(),
mockTrackEvent: vi.fn(),
mockGetAudioPlayer: vi.fn(),
mockResetMsgId: vi.fn(),
mockCreateBaseWorkflowRunCallbacks: vi.fn(),
mockCreateFinalWorkflowRunCallbacks: vi.fn(),
runEventHandlers: {
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
},
}
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => mocks.reactFlowStoreState,
}),
useReactFlow: () => ({
getViewport: mocks.mockGetViewport,
}),
}))
vi.mock('@/app/components/app/store', () => {
const useStore = Object.assign(vi.fn(), {
getState: () => mocks.appStoreState,
})
return {
useStore,
}
})
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mocks.mockTrackEvent,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
getAudioPlayer: mocks.mockGetAudioPlayer,
resetMsgId: mocks.mockResetMsgId,
}),
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => mocks.featuresStoreState,
setState: mocks.featuresStoreSetState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
useWorkflowRunEvent: () => mocks.runEventHandlers,
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => mocks.workflowStoreState,
setState: mocks.workflowStoreSetState,
}),
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => '/apps/app-1/workflow',
}))
vi.mock('@/service/base', () => ({
ssePost: mocks.mockSsePost,
sseGet: mocks.mockSseGet,
post: mocks.mockPost,
handleStream: mocks.mockHandleStream,
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun,
useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory,
useInvalidateConversationVarValues: () => vi.fn(),
useInvalidateSysVarValues: () => vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
stopWorkflowRun: mocks.mockStopWorkflowRun,
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mocks.mockFetchInspectVars,
}),
}))
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'flow-1',
flowType: 'workflow',
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft,
}),
}))
vi.mock('../use-workflow-run-callbacks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../use-workflow-run-callbacks')>()
return {
...actual,
createBaseWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateBaseWorkflowRunCallbacks(params)
return actual.createBaseWorkflowRunCallbacks(params)
}),
createFinalWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateFinalWorkflowRunCallbacks(params)
return actual.createFinalWorkflowRunCallbacks(params)
}),
}
})
const createWorkflowStoreState = () => ({
backupDraft: undefined,
environmentVariables: [{ id: 'env-current', value: 'secret' }],
setBackupDraft: vi.fn((value: unknown) => {
mocks.workflowStoreState.backupDraft = value
}),
setEnvironmentVariables: vi.fn((value: unknown) => {
mocks.workflowStoreState.environmentVariables = value
}),
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = '<div id="workflow-container"></div>'
const workflowContainer = document.getElementById('workflow-container')!
Object.defineProperty(workflowContainer, 'clientWidth', { value: 960, configurable: true })
Object.defineProperty(workflowContainer, 'clientHeight', { value: 540, configurable: true })
mocks.reactFlowStoreState.getNodes.mockReturnValue([
{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } },
])
mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 })
mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mocks.mockPost.mockResolvedValue(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
mocks.mockGetAudioPlayer.mockReturnValue({
playAudioWithAudio: vi.fn(),
})
mocks.workflowStoreState.backupDraft = undefined
Object.assign(mocks.workflowStoreState, createWorkflowStoreState())
mocks.workflowStoreSetState.mockImplementation((partial: Record<string, unknown>) => {
Object.assign(mocks.workflowStoreState, partial)
})
mocks.featuresStoreState.features = {
file: {
enabled: true,
},
}
})
it('should backup the current draft once and skip subsequent backups until it is cleared', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleBackupDraft()
result.current.handleBackupDraft()
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledTimes(1)
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith({
nodes: [{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } }],
edges: [{ id: 'edge-1' }],
viewport: { x: 1, y: 2, zoom: 1.5 },
features: { file: { enabled: true } },
environmentVariables: [{ id: 'env-current', value: 'secret' }],
})
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should load a backup draft into canvas, environment variables, and features state', () => {
mocks.workflowStoreState.backupDraft = {
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
features: { opening: { enabled: true } },
environmentVariables: [{ id: 'env-backup', value: 'value' }],
}
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleLoadBackupDraft()
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }])
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: { opening: { enabled: true } },
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith(undefined)
})
it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
expect(mocks.reactFlowStoreState.setNodes).toHaveBeenCalledWith([
{ id: 'node-1', data: { selected: false, _runningStatus: undefined } },
])
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mocks.workflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Running,
}),
}))
expect(mocks.mockSsePost).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/run',
{ body: { inputs: { query: 'hello' } } },
expect.objectContaining({
getAbortController: expect.any(Function),
}),
)
})
it.each([
{
title: 'schedule',
params: {},
options: { mode: TriggerType.Schedule, scheduleNodeId: 'schedule-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'schedule-1' },
expectedNodeIds: ['schedule-1'],
expectedIsAll: false,
},
{
title: 'webhook',
params: { node_id: 'webhook-1' },
options: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'webhook-1' },
expectedNodeIds: ['webhook-1'],
expectedIsAll: false,
},
{
title: 'plugin',
params: { node_id: 'plugin-1' },
options: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'plugin-1' },
expectedNodeIds: ['plugin-1'],
expectedIsAll: false,
},
{
title: 'all',
params: { node_ids: ['trigger-1', 'trigger-2'] },
options: { mode: TriggerType.All, allNodeIds: ['trigger-1', 'trigger-2'] },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run-all',
expectedBody: { node_ids: ['trigger-1', 'trigger-2'] },
expectedNodeIds: ['trigger-1', 'trigger-2'],
expectedIsAll: true,
},
])('should dispatch $title trigger runs through the debug runner integration', async ({
params,
options,
expectedUrl,
expectedBody,
expectedNodeIds,
expectedIsAll,
}) => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun(params, undefined, options)
})
expect(mocks.mockPost).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
body: expectedBody,
signal: expect.any(AbortSignal),
}),
{ needAllResponseContent: true },
)
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds)
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll)
expect(mocks.mockSsePost).not.toHaveBeenCalled()
})
it('should expose the workflow-failed tracker through the callback factory context', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
trackWorkflowRunFailed: (params: { error?: string, node_type?: string }) => void
}
baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' })
expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', {
workflow_id: 'flow-1',
reason: 'failed',
node_type: 'llm',
})
})
it('should lazily create audio players with the correct public and private tts urls', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ token: 'public-token' })
})
const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
publicBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/text-to-audio',
true,
expect.any(String),
'none',
'none',
expect.any(Function),
)
mocks.mockSsePost.mockClear()
mocks.mockGetAudioPlayer.mockClear()
await act(async () => {
await result.current.handleRun({ appId: 'app-2' })
})
const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
privateBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/apps/app-2/text-to-audio',
false,
expect.any(String),
'none',
'none',
expect.any(Function),
)
})
it('should stop workflow runs by task id or by aborting active debug controllers', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
act(() => {
result.current.handleStopRun('task-1')
})
expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop')
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Stopped,
}),
}))
const webhookAbort = vi.fn()
const pluginAbort = vi.fn()
const scheduleAbort = vi.fn()
const allTriggersAbort = vi.fn()
const windowWithDebugControllers = window as DebugControllerWindow
windowWithDebugControllers.__webhookDebugAbortController = { abort: webhookAbort }
windowWithDebugControllers.__pluginDebugAbortController = { abort: pluginAbort }
windowWithDebugControllers.__scheduleDebugAbortController = { abort: scheduleAbort }
windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort }
const refController = new AbortController()
const refAbortSpy = vi.spyOn(refController, 'abort')
const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as {
getAbortController?: (controller: AbortController) => void
}
getAbortController?.(refController)
act(() => {
result.current.handleStopRun('')
})
expect(webhookAbort).toHaveBeenCalled()
expect(pluginAbort).toHaveBeenCalled()
expect(scheduleAbort).toHaveBeenCalled()
expect(allTriggersAbort).toHaveBeenCalled()
expect(refAbortSpy).toHaveBeenCalled()
})
it('should restore published workflow graph, features, and environment variables', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-published', value: 'value' }],
} as never)
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({
enabled: true,
opening_statement: 'hello',
}),
file: { enabled: true },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }])
})
it('should restore published workflows with empty environment variables as an empty list', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: false },
},
} as never)
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({ enabled: false }),
file: { enabled: false },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([])
})
})
@@ -0,0 +1,391 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import {
BlockEnum,
WorkflowRunningStatus,
} from '@/app/components/workflow/types'
import { useWorkflowStartRun } from '../use-workflow-start-run'
const mockGetNodes = vi.fn()
const mockGetFeaturesState = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockHandleRun = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockUseIsChatMode = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowGlobalVariablePanel = vi.fn()
const mockSetShowChatVariablePanel = vi.fn()
const mockSetListeningTriggerType = vi.fn()
const mockSetListeningTriggerNodeId = vi.fn()
const mockSetListeningTriggerNodeIds = vi.fn()
const mockSetListeningTriggerIsAll = vi.fn()
const mockSetHistoryWorkflowData = vi.fn()
let workflowStoreState: Record<string, unknown>
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: mockGetFeaturesState,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useWorkflowRun: () => ({
handleRun: mockHandleRun,
}),
}))
const createWorkflowStoreState = (overrides: Record<string, unknown> = {}) => ({
workflowRunningData: undefined,
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
setShowChatVariablePanel: mockSetShowChatVariablePanel,
setListeningTriggerType: mockSetListeningTriggerType,
setListeningTriggerNodeId: mockSetListeningTriggerNodeId,
setListeningTriggerNodeIds: mockSetListeningTriggerNodeIds,
setListeningTriggerIsAll: mockSetListeningTriggerIsAll,
setHistoryWorkflowData: mockSetHistoryWorkflowData,
...overrides,
})
describe('useWorkflowStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = createWorkflowStoreState()
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [] } },
])
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: false,
},
},
},
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockUseIsChatMode.mockReturnValue(false)
})
it('should run the workflow immediately when there are no start variables and no image upload input', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {}, files: [] })
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should open the input panel instead of running immediately when start inputs are required', async () => {
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [{ name: 'query' }] } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should open the input panel when image upload is enabled even without start variables', async () => {
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: true,
},
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should cancel the current debug panel instead of starting another workflow when one is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should short-circuit workflow start when a run is already in progress', async () => {
workflowStoreState = createWorkflowStoreState({
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should configure schedule trigger runs and execute the workflow with schedule options', async () => {
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(BlockEnum.TriggerSchedule)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith('schedule-1')
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1'])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{},
undefined,
{
mode: TriggerType.Schedule,
scheduleNodeId: 'schedule-1',
},
)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should cancel schedule trigger execution when the debug panel is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined),
},
{
title: 'webhook',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }),
},
{
title: 'plugin',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow(''),
},
])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'),
},
{
title: 'webhook',
warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }),
},
{
title: 'plugin',
warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'),
},
])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing'))
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it.each([
{
title: 'webhook',
nodeId: 'webhook-1',
nodeType: BlockEnum.TriggerWebhook,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }),
expectedParams: { node_id: 'webhook-1' },
expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
},
{
title: 'plugin',
nodeId: 'plugin-1',
nodeType: BlockEnum.TriggerPlugin,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'),
expectedParams: { node_id: 'plugin-1' },
expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
},
])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => {
mockGetNodes.mockReturnValue([
{ id: nodeId, data: { type: nodeType } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions)
})
it('should run all triggers and mark the listener state as global', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow(['trigger-1', 'trigger-2'])
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{ node_ids: ['trigger-1', 'trigger-2'] },
undefined,
{
mode: TriggerType.All,
allNodeIds: ['trigger-1', 'trigger-2'],
},
)
})
it('should ignore run-all requests when there are no trigger nodes', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow([])
})
expect(mockSetListeningTriggerIsAll).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should route handleStartWorkflowRun to the chatflow path when chat mode is enabled', async () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
result.current.handleStartWorkflowRun()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetHistoryWorkflowData).toHaveBeenCalledWith(undefined)
expect(mockHandleRun).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,82 @@
import { renderHook } from '@testing-library/react'
import { useWorkflowTemplate } from '../use-workflow-template'
const mockUseIsChatMode = vi.fn()
let generateNewNodeCalls: Array<Record<string, unknown>> = []
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: { id?: string, data: Record<string, unknown>, position: Record<string, unknown> }) => {
generateNewNodeCalls.push(args)
return {
newNode: {
id: args.id ?? `generated-${generateNewNodeCalls.length}`,
data: args.data,
position: args.position,
},
}
},
}
})
describe('useWorkflowTemplate', () => {
beforeEach(() => {
vi.clearAllMocks()
generateNewNodeCalls = []
})
it('should return only the start node template in workflow mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(1)
expect(result.current.edges).toEqual([])
expect(generateNewNodeCalls).toHaveLength(1)
})
it('should build start, llm, and answer templates with linked edges in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(3)
expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer'])
expect(result.current.edges).toEqual([
{
id: 'generated-1-llm',
source: 'generated-1',
sourceHandle: 'source',
target: 'llm',
targetHandle: 'target',
},
{
id: 'llm-answer',
source: 'llm',
sourceHandle: 'source',
target: 'answer',
targetHandle: 'target',
},
])
expect(generateNewNodeCalls).toHaveLength(3)
expect(generateNewNodeCalls[0].data).toMatchObject({
type: 'start',
title: 'workflow.blocks.start',
})
expect(generateNewNodeCalls[1].data).toMatchObject({
type: 'llm',
title: 'workflow.blocks.llm',
})
expect(generateNewNodeCalls[2].data).toMatchObject({
type: 'answer',
title: 'workflow.blocks.answer',
answer: '{{#llm.text#}}',
})
})
})
@@ -0,0 +1,470 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { IOtherOptions } from '@/service/base'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { sseGet } from '@/service/base'
type ContainerSize = {
clientWidth: number
clientHeight: number
}
type WorkflowRunEventHandlers = {
handleWorkflowStarted: NonNullable<IOtherOptions['onWorkflowStarted']>
handleWorkflowFinished: NonNullable<IOtherOptions['onWorkflowFinished']>
handleWorkflowFailed: () => void
handleWorkflowNodeStarted: (params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeFinished: NonNullable<IOtherOptions['onNodeFinished']>
handleWorkflowNodeHumanInputRequired: NonNullable<IOtherOptions['onHumanInputRequired']>
handleWorkflowNodeHumanInputFormFilled: NonNullable<IOtherOptions['onHumanInputFormFilled']>
handleWorkflowNodeHumanInputFormTimeout: NonNullable<IOtherOptions['onHumanInputFormTimeout']>
handleWorkflowNodeIterationStarted: (params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeIterationNext: NonNullable<IOtherOptions['onIterationNext']>
handleWorkflowNodeIterationFinished: NonNullable<IOtherOptions['onIterationFinish']>
handleWorkflowNodeLoopStarted: (params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeLoopNext: NonNullable<IOtherOptions['onLoopNext']>
handleWorkflowNodeLoopFinished: NonNullable<IOtherOptions['onLoopFinish']>
handleWorkflowNodeRetry: NonNullable<IOtherOptions['onNodeRetry']>
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
handleWorkflowPaused: () => void
}
type UserCallbackHandlers = {
onWorkflowStarted?: IOtherOptions['onWorkflowStarted']
onWorkflowFinished?: IOtherOptions['onWorkflowFinished']
onNodeStarted?: IOtherOptions['onNodeStarted']
onNodeFinished?: IOtherOptions['onNodeFinished']
onIterationStart?: IOtherOptions['onIterationStart']
onIterationNext?: IOtherOptions['onIterationNext']
onIterationFinish?: IOtherOptions['onIterationFinish']
onLoopStart?: IOtherOptions['onLoopStart']
onLoopNext?: IOtherOptions['onLoopNext']
onLoopFinish?: IOtherOptions['onLoopFinish']
onNodeRetry?: IOtherOptions['onNodeRetry']
onAgentLog?: IOtherOptions['onAgentLog']
onError?: IOtherOptions['onError']
onWorkflowPaused?: IOtherOptions['onWorkflowPaused']
onHumanInputRequired?: IOtherOptions['onHumanInputRequired']
onHumanInputFormFilled?: IOtherOptions['onHumanInputFormFilled']
onHumanInputFormTimeout?: IOtherOptions['onHumanInputFormTimeout']
onCompleted?: IOtherOptions['onCompleted']
}
type CallbackContext = {
clientWidth: number
clientHeight: number
runHistoryUrl: string
isInWorkflowDebug: boolean
fetchInspectVars: (params: Record<string, never>) => void
invalidAllLastRun: () => void
invalidateRunHistory: (url: string) => void
clearAbortController: () => void
clearListeningState: () => void
trackWorkflowRunFailed: (params: unknown) => void
handlers: WorkflowRunEventHandlers
callbacks: UserCallbackHandlers
restCallback: IOtherOptions
}
type BaseCallbacksContext = CallbackContext & {
getOrCreatePlayer: () => AudioPlayer | null
}
type FinalCallbacksContext = CallbackContext & {
baseSseOptions: IOtherOptions
player: AudioPlayer | null
setAbortController: (controller: AbortController) => void
}
export const createBaseWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
getOrCreatePlayer,
}: BaseCallbacksContext): IOtherOptions => {
const {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
} = callbacks
const wrappedOnError: IOtherOptions['onError'] = (params, code) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params, code)
trackWorkflowRunFailed(params)
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (_messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, baseSseOptions)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
return baseSseOptions
}
export const createFinalWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: _clearAbortController,
clearListeningState: _clearListeningState,
trackWorkflowRunFailed: _trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
baseSseOptions,
player,
setAbortController,
}: FinalCallbacksContext): IOtherOptions => {
const {
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
} = callbacks
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
setAbortController(controller)
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params, code) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params, code)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (_messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, finalCallbacks)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
return finalCallbacks
}
@@ -0,0 +1,443 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { TriggerNodeType } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { toast } from '@/app/components/base/ui/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { handleStream, post } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { AppModeEnum } from '@/types/app'
export type HandleRunMode = TriggerType
export type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
export type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
type AppDetailLike = {
id?: string
mode?: AppModeEnum
}
type TTSParamsLike = {
token?: string
appId?: string
}
type ListeningStateActions = {
setWorkflowRunningData: (data: ReturnType<typeof createRunningWorkflowState> | ReturnType<typeof createFailedWorkflowState> | ReturnType<typeof createStoppedWorkflowState>) => void
setIsListening: (value: boolean) => void
setShowVariableInspectPanel: (value: boolean) => void
setListeningTriggerType: (value: TriggerNodeType | null) => void
setListeningTriggerNodeIds: (value: string[]) => void
setListeningTriggerIsAll: (value: boolean) => void
setListeningTriggerNodeId: (value: string | null) => void
}
type TriggerDebugRunnerOptions = {
debugType: DebuggableTriggerType
url: string
requestBody: unknown
baseSseOptions: IOtherOptions
controllerTarget: Record<string, unknown>
setAbortController: (controller: AbortController | null) => void
clearAbortController: () => void
clearListeningState: () => void
setWorkflowRunningData: ListeningStateActions['setWorkflowRunningData']
}
export const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
export const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
export const createRunningWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createStoppedWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createFailedWorkflowState = (error: string) => {
return {
result: {
status: WorkflowRunningStatus.Failed,
error,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
}
}
export const buildRunHistoryUrl = (appDetail?: AppDetailLike) => {
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
}
export const resolveWorkflowRunUrl = (
appDetail: AppDetailLike | undefined,
runMode: HandleRunMode,
isInWorkflowDebug: boolean,
) => {
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT)
return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
if (isInWorkflowDebug && appDetail?.id)
return `/apps/${appDetail.id}/workflows/draft/run`
return ''
}
export const buildWorkflowRunRequestBody = (
runMode: HandleRunMode,
resolvedParams: Record<string, unknown>,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule)
return { node_id: options?.scheduleNodeId }
if (runMode === TriggerType.Webhook)
return { node_id: options?.webhookNodeId }
if (runMode === TriggerType.Plugin)
return { node_id: options?.pluginNodeId }
if (runMode === TriggerType.All)
return { node_ids: options?.allNodeIds }
return resolvedParams
}
export const validateWorkflowRunRequest = (
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId)
return 'handleRun: schedule trigger run requires node id'
if (runMode === TriggerType.Webhook && !options?.webhookNodeId)
return 'handleRun: webhook trigger run requires node id'
if (runMode === TriggerType.Plugin && !options?.pluginNodeId)
return 'handleRun: plugin trigger run requires node id'
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0)
return 'handleRun: all trigger run requires node ids'
return ''
}
export const isDebuggableTriggerType = (
runMode: HandleRunMode,
): runMode is DebuggableTriggerType => {
return (
runMode === TriggerType.Schedule
|| runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
)
}
export const buildListeningTriggerNodeIds = (
runMode: DebuggableTriggerType,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.All)
return options?.allNodeIds ?? []
if (runMode === TriggerType.Webhook && options?.webhookNodeId)
return [options.webhookNodeId]
if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
return [options.scheduleNodeId]
if (runMode === TriggerType.Plugin && options?.pluginNodeId)
return [options.pluginNodeId]
return []
}
export const applyRunningStateForMode = (
actions: ListeningStateActions,
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (isDebuggableTriggerType(runMode)) {
actions.setIsListening(true)
actions.setShowVariableInspectPanel(true)
actions.setListeningTriggerIsAll(runMode === TriggerType.All)
actions.setListeningTriggerNodeIds(buildListeningTriggerNodeIds(runMode, options))
actions.setWorkflowRunningData(createRunningWorkflowState())
return
}
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
actions.setWorkflowRunningData(createRunningWorkflowState())
}
export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsListening' | 'setListeningTriggerType' | 'setListeningTriggerNodeId' | 'setListeningTriggerNodeIds' | 'setListeningTriggerIsAll'>) => {
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
}
export const applyStoppedState = (actions: Pick<ListeningStateActions, 'setWorkflowRunningData' | 'setIsListening' | 'setShowVariableInspectPanel' | 'setListeningTriggerType' | 'setListeningTriggerNodeId'>) => {
actions.setWorkflowRunningData(createStoppedWorkflowState())
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setShowVariableInspectPanel(true)
}
export const clearWindowDebugControllers = (controllerTarget: Record<string, unknown>) => {
delete controllerTarget.__webhookDebugAbortController
delete controllerTarget.__pluginDebugAbortController
delete controllerTarget.__scheduleDebugAbortController
delete controllerTarget.__allTriggersDebugAbortController
}
export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) => {
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
return {
ttsUrl,
ttsIsPublic,
}
}
export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => {
return {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
}
export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => {
return publishedWorkflow.graph.nodes.map(node => ({
...node,
selected: false,
data: {
...node.data,
selected: false,
},
}))
}
export const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
export const runTriggerDebug = async ({
debugType,
url,
requestBody,
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState,
setWorkflowRunningData,
}: TriggerDebugRunnerOptions) => {
const controller = new AbortController()
setAbortController(controller)
const controllerKey = controllerKeyMap[debugType]
controllerTarget[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
toast.error(message)
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: Record<string, unknown> | null = null
try {
data = await response.json() as Record<string, unknown>
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
toast.error(`${debugLabel} debug request failed`)
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed`
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, unknown>
const errorMessage = typeof data?.error === 'string' ? data.error : ''
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
}
clearListeningState()
}
}
await poll()
}
@@ -1,3 +1,4 @@
import type { HandleRunOptions } from './use-workflow-run-utils'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { Node } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
@@ -14,46 +15,38 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { usePathname } from '@/next/navigation'
import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { ssePost } from '@/service/base'
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
import { useConfigsMap } from './use-configs-map'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import {
createBaseWorkflowRunCallbacks,
createFinalWorkflowRunCallbacks,
} from './use-workflow-run-callbacks'
import {
applyRunningStateForMode,
applyStoppedState,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
type HandleRunMode = TriggerType
type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
isDebuggableTriggerType,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from './use-workflow-run-utils'
export const useWorkflowRun = () => {
const store = useStoreApi()
@@ -152,7 +145,7 @@ export const useWorkflowRun = () => {
callback?: IOtherOptions,
options?: HandleRunOptions,
) => {
const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput
const runMode = options?.mode ?? TriggerType.UserInput
const resolvedParams = params ?? {}
const {
getNodes,
@@ -190,9 +183,7 @@ export const useWorkflowRun = () => {
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const runHistoryUrl = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
const runHistoryUrl = buildRunHistoryUrl(appDetail)
const workflowContainer = document.getElementById('workflow-container')
const {
@@ -202,65 +193,15 @@ export const useWorkflowRun = () => {
const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW
let url = ''
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
else if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
}
else if (isInWorkflowDebug && appDetail?.id) {
url = `/apps/${appDetail.id}/workflows/draft/run`
}
let requestBody = {}
if (runMode === TriggerType.Schedule)
requestBody = { node_id: options?.scheduleNodeId }
else if (runMode === TriggerType.Webhook)
requestBody = { node_id: options?.webhookNodeId }
else if (runMode === TriggerType.Plugin)
requestBody = { node_id: options?.pluginNodeId }
else if (runMode === TriggerType.All)
requestBody = { node_ids: options?.allNodeIds }
else
requestBody = resolvedParams
const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug)
const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options)
if (!url)
return
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) {
console.error('handleRun: schedule trigger run requires node id')
return
}
if (runMode === TriggerType.Webhook && !options?.webhookNodeId) {
console.error('handleRun: webhook trigger run requires node id')
return
}
if (runMode === TriggerType.Plugin && !options?.pluginNodeId) {
console.error('handleRun: plugin trigger run requires node id')
return
}
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) {
console.error('handleRun: all trigger run requires node ids')
const validationMessage = validateWorkflowRunRequest(runMode, options)
if (validationMessage) {
console.error(validationMessage)
return
}
@@ -277,66 +218,17 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
if (
runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
|| runMode === TriggerType.Schedule
) {
setIsListening(true)
setShowVariableInspectPanel(true)
setListeningTriggerIsAll(runMode === TriggerType.All)
if (runMode === TriggerType.All)
setListeningTriggerNodeIds(options?.allNodeIds ?? [])
else if (runMode === TriggerType.Webhook && options?.webhookNodeId)
setListeningTriggerNodeIds([options.webhookNodeId])
else if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
setListeningTriggerNodeIds([options.scheduleNodeId])
else if (runMode === TriggerType.Plugin && options?.pluginNodeId)
setListeningTriggerNodeIds([options.pluginNodeId])
else
setListeningTriggerNodeIds([])
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
else {
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setListeningTriggerNodeIds([])
setListeningTriggerIsAll(false)
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
applyRunningStateForMode({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
setListeningTriggerNodeId,
}, runMode, options)
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname)
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
// This prevents opening audio channel unnecessarily
let player: AudioPlayer | null = null
@@ -349,497 +241,121 @@ export const useWorkflowRun = () => {
const clearAbortController = () => {
abortControllerRef.current = null
delete (window as any).__webhookDebugAbortController
delete (window as any).__pluginDebugAbortController
delete (window as any).__scheduleDebugAbortController
delete (window as any).__allTriggersDebugAbortController
clearWindowDebugControllers(window as unknown as Record<string, unknown>)
}
const clearListeningState = () => {
const clearListeningStateInStore = () => {
const state = workflowStore.getState()
state.setIsListening(false)
state.setListeningTriggerType(null)
state.setListeningTriggerNodeId(null)
state.setListeningTriggerNodeIds([])
state.setListeningTriggerIsAll(false)
clearListeningState({
setIsListening: state.setIsListening,
setListeningTriggerType: state.setListeningTriggerType,
setListeningTriggerNodeId: state.setListeningTriggerNodeId,
setListeningTriggerNodeIds: state.setListeningTriggerNodeIds,
setListeningTriggerIsAll: state.setListeningTriggerIsAll,
})
}
const wrappedOnError = (params: any) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params)
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type })
const workflowRunEventHandlers = {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
}
const userCallbacks = {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
const trackWorkflowRunFailed = (eventParams: unknown) => {
const payload = eventParams as { error?: string, node_type?: string }
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: payload?.error, node_type: payload?.node_type })
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
baseSseOptions,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
getOrCreatePlayer,
})
const runTriggerDebug = async (debugType: DebuggableTriggerType) => {
const controller = new AbortController()
abortControllerRef.current = controller
const controllerKey = controllerKeyMap[debugType]
; (window as any)[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
Toast.notify({ type: 'error', message })
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: any = null
try {
data = await response.json()
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` })
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = data?.message || `${debugLabel} debug failed`
Toast.notify({ type: 'error', message: errorMessage })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: errorMessage,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, any>
const { error: respError } = data || {}
Toast.notify({ type: 'error', message: respError })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: respError,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
}
clearListeningState()
}
}
await poll()
}
if (runMode === TriggerType.Schedule) {
await runTriggerDebug(TriggerType.Schedule)
if (isDebuggableTriggerType(runMode)) {
await runTriggerDebug({
debugType: runMode,
url,
requestBody,
baseSseOptions,
controllerTarget: window as unknown as Record<string, unknown>,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
clearAbortController,
clearListeningState: clearListeningStateInStore,
setWorkflowRunningData,
})
return
}
if (runMode === TriggerType.Webhook) {
await runTriggerDebug(TriggerType.Webhook)
return
}
if (runMode === TriggerType.Plugin) {
await runTriggerDebug(TriggerType.Plugin)
return
}
if (runMode === TriggerType.All) {
await runTriggerDebug(TriggerType.All)
return
}
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
baseSseOptions,
player,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
finalCallbacks,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
})
ssePost(
url,
@@ -860,20 +376,13 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
applyStoppedState({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeId,
})
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setShowVariableInspectPanel(true)
}
if (taskId) {
@@ -909,7 +418,7 @@ export const useWorkflowRun = () => {
}, [workflowStore])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const nodes = normalizePublishedWorkflowNodes(publishedWorkflow)
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
handleUpdateWorkflowCanvas({
@@ -917,21 +426,7 @@ export const useWorkflowRun = () => {
edges,
viewport,
})
const mappedFeatures = {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
featuresStore?.setState({ features: mappedFeatures })
featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) })
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
+12 -70
View File
@@ -9,16 +9,12 @@ import {
import { useStore as useAppStore } from '@/app/components/app/store'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import {
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import {
initialEdges,
initialNodes,
@@ -35,6 +31,11 @@ import {
useWorkflowInit,
} from './hooks/use-workflow-init'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from './utils'
const WorkflowAppWithAdditionalContext = () => {
const {
@@ -58,13 +59,7 @@ const WorkflowAppWithAdditionalContext = () => {
// Sync trigger statuses to store when data loads
useEffect(() => {
if (triggersResponse?.data) {
// Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled'
const statusMap = triggersResponse.data.reduce((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {} as Record<string, 'enabled' | 'disabled'>)
setTriggerStatuses(statusMap)
setTriggerStatuses(buildTriggerStatusMap(triggersResponse.data))
}
}, [triggersResponse?.data, setTriggerStatuses])
@@ -108,49 +103,21 @@ const WorkflowAppWithAdditionalContext = () => {
fetchRunDetail(runUrl).then((res) => {
const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState()
const rawInputs = res.inputs
let parsedInputs: Record<string, unknown> | null = null
let parsedInputs: unknown = rawInputs
if (typeof rawInputs === 'string') {
try {
const maybeParsed = JSON.parse(rawInputs) as unknown
if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed))
parsedInputs = maybeParsed as Record<string, unknown>
parsedInputs = JSON.parse(rawInputs) as unknown
}
catch (error) {
console.error('Failed to parse workflow run inputs', error)
return
}
}
else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) {
parsedInputs = rawInputs as Record<string, unknown>
}
if (!parsedInputs)
return
const userInputs = coerceReplayUserInputs(parsedInputs)
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(parsedInputs).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
if (!Object.keys(userInputs).length)
if (!userInputs || !Object.keys(userInputs).length)
return
setInputs(userInputs)
@@ -167,32 +134,7 @@ const WorkflowAppWithAdditionalContext = () => {
)
}
const features = data.features || {}
const initialFeatures: FeaturesData = {
file: {
image: {
enabled: !!features.file_upload?.image?.enabled,
number_limits: features.file_upload?.image?.number_limits || 3,
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse)
return (
<WorkflowWithDefaultContext
@@ -0,0 +1,44 @@
import { createStore } from 'zustand/vanilla'
import { createWorkflowSlice } from '../workflow-slice'
describe('createWorkflowSlice', () => {
it('should initialize workflow slice state with expected defaults', () => {
const store = createStore(createWorkflowSlice)
const state = store.getState()
expect(state.appId).toBe('')
expect(state.appName).toBe('')
expect(state.notInitialWorkflow).toBe(false)
expect(state.shouldAutoOpenStartNodeSelector).toBe(false)
expect(state.nodesDefaultConfigs).toEqual({})
expect(state.showOnboarding).toBe(false)
expect(state.hasSelectedStartNode).toBe(false)
expect(state.hasShownOnboarding).toBe(false)
})
it('should update every workflow slice field through its setters', () => {
const store = createStore(createWorkflowSlice)
store.setState({
appId: 'app-1',
appName: 'Workflow App',
})
store.getState().setNotInitialWorkflow(true)
store.getState().setShouldAutoOpenStartNodeSelector(true)
store.getState().setNodesDefaultConfigs({ start: { title: 'Start' } })
store.getState().setShowOnboarding(true)
store.getState().setHasSelectedStartNode(true)
store.getState().setHasShownOnboarding(true)
expect(store.getState()).toMatchObject({
appId: 'app-1',
appName: 'Workflow App',
notInitialWorkflow: true,
shouldAutoOpenStartNodeSelector: true,
nodesDefaultConfigs: { start: { title: 'Start' } },
showOnboarding: true,
hasSelectedStartNode: true,
hasShownOnboarding: true,
})
})
})
+107
View File
@@ -0,0 +1,107 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
type TriggerStatusLike = {
node_id: string
status: string
}
type FileUploadFeatureLike = {
enabled?: boolean
allowed_file_types?: SupportUploadFileTypes[]
allowed_file_extensions?: string[]
allowed_file_upload_methods?: TransferMethod[]
number_limits?: number
image?: {
enabled?: boolean
number_limits?: number
transfer_methods?: TransferMethod[]
}
}
type WorkflowFeaturesLike = {
file_upload?: FileUploadFeatureLike
opening_statement?: string
suggested_questions?: string[]
suggested_questions_after_answer?: { enabled?: boolean }
speech_to_text?: { enabled?: boolean }
text_to_speech?: { enabled?: boolean }
retriever_resource?: { enabled?: boolean }
sensitive_word_avoidance?: { enabled?: boolean }
}
export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => {
return triggers.reduce<Record<string, 'enabled' | 'disabled'>>((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {})
}
export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, string | number | boolean> | null => {
if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs))
return null
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(rawInputs as Record<string, unknown>).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
return userInputs
}
export const buildInitialFeatures = (
featuresSource: WorkflowFeaturesLike | null | undefined,
fileUploadConfigResponse: FileUploadConfigResponse | undefined,
): FeaturesData => {
const features = featuresSource || {}
const fileUpload = features.file_upload
const imageUpload = fileUpload?.image
return {
file: {
image: {
enabled: !!imageUpload?.enabled,
number_limits: imageUpload?.number_limits || 3,
transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
},
enabled: !!(fileUpload?.enabled || imageUpload?.enabled),
allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
}
@@ -2,6 +2,9 @@ import { renderHook } from '@testing-library/react'
import useNodeResizeObserver from '../use-node-resize-observer'
describe('useNodeResizeObserver', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('should observe and disconnect when enabled with a mounted node ref', () => {
const observe = vi.fn()
const disconnect = vi.fn()
@@ -57,6 +57,16 @@ describe('before-run-form helpers', () => {
values: createValues({ query: '' }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile, required: true })],
values: createValues({ file: [] }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })],
values: createValues({ files: [] }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })],
values: createValues({ file: { transferMethod: TransferMethod.local_file } }),
@@ -56,7 +56,16 @@ export const getFormErrorMessage = (
const missingRequired = input.required
&& input.type !== InputVarType.checkbox
&& !(input.variable in existVarValuesInForm)
&& (value === '' || value === undefined || value === null || (input.type === InputVarType.files && Array.isArray(value) && value.length === 0))
&& (
value === '' || value === undefined || value === null
|| (
(input.type === InputVarType.files
|| input.type === InputVarType.multiFiles
|| input.type === InputVarType.singleFile)
&& Array.isArray(value)
&& value.length === 0
)
)
if (!errMsg && missingRequired) {
errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label })
@@ -75,16 +75,12 @@ describe('workflow-panel helpers', () => {
})
describe('custom run form fallback', () => {
it('should return a fallback message for unsupported custom run form nodes', () => {
it('should return null for unsupported custom run form nodes', () => {
const form = getCustomRunForm({
...createCustomRunFormProps({ type: BlockEnum.Tool }),
})
expect(form).toMatchObject({
props: {
children: expect.arrayContaining(['Custom Run Form:', ' ', 'not found']),
},
})
expect(form).toBeNull()
})
})
})
@@ -39,14 +39,7 @@ export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => {
case BlockEnum.DataSource:
return <DataSourceBeforeRunForm {...params} />
default:
return (
<div>
Custom Run Form:
{nodeType}
{' '}
not found
</div>
)
return null
}
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import GenericTable from '../generic-table'
@@ -50,8 +50,19 @@ const advancedColumns = [
describe('GenericTable', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
const selectOption = async (triggerName: string, optionName: string) => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
})
await act(async () => {
fireEvent.click(await screen.findByRole('option', { name: optionName }))
})
}
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
const onChange = vi.fn()
@@ -143,11 +154,11 @@ describe('GenericTable', () => {
<ControlledTable />,
)
await user.click(screen.getByRole('button', { name: 'Choose method' }))
await user.click(await screen.findByRole('option', { name: 'POST' }))
await selectOption('Choose method', 'POST')
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
expect(screen.getByRole('button', { name: 'POST' })).toBeInTheDocument()
})
onChange.mockClear()
@@ -90,6 +90,22 @@ describe('useVariableModalState', () => {
])
})
it('should keep valid object rows when switching to json mode from form mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.Object)
result.current.setObjectValue([
{ key: '', type: ChatVarType.String, value: undefined },
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual({ timeout: 30 })
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
})
it('should reset object form values when leaving empty json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
@@ -141,6 +157,19 @@ describe('useVariableModalState', () => {
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
})
it('should preserve zero values when switching number arrays into json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayNumber)
result.current.setValue([0, 2, undefined])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual([0, 2])
expect(result.current.editorContent).toBe(JSON.stringify([0, 2]))
})
it('should notify and stop saving when object keys are invalid', () => {
const notify = vi.fn()
const onSave = vi.fn()
@@ -161,7 +190,7 @@ describe('useVariableModalState', () => {
result.current.handleSave()
})
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'object key can not be empty' })
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' })
expect(onSave).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
@@ -33,6 +33,10 @@ describe('variable-modal helpers', () => {
{ key: '', type: ChatVarType.Number, value: 1 },
])).toEqual({ apiKey: 'secret' })
expect(formatObjectValueFromList([
{ key: 'count', type: ChatVarType.Number, value: 0 },
{ key: 'label', type: ChatVarType.String, value: '' },
])).toEqual({ count: 0, label: null })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
@@ -54,6 +58,13 @@ describe('variable-modal helpers', () => {
value: ['a', '', 'b'],
})).toEqual(['a', 'b'])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayNumber,
value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>,
})).toEqual([0, 1])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
@@ -94,6 +105,10 @@ describe('variable-modal helpers', () => {
type: ChatVarType.ArrayBoolean,
})).toEqual([true, false, true, false])
expect(() => parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.ArrayBoolean,
})).toThrow('JSON array')
expect(parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.Object,
@@ -80,7 +80,7 @@ describe('variable-modal', () => {
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
await user.click(screen.getByText('common.operation.save'))
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed')
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}')
expect(onSave).not.toHaveBeenCalled()
})
@@ -100,8 +100,10 @@ describe('variable-modal', () => {
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
const timeoutInput = screen.getByDisplayValue('30') as HTMLInputElement
await user.clear(screen.getByDisplayValue('secret'))
await user.type(screen.getByDisplayValue('30'), '5')
await user.clear(timeoutInput)
await user.type(timeoutInput, '5')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
@@ -110,7 +112,7 @@ describe('variable-modal', () => {
value_type: ChatVarType.Object,
value: {
apiKey: null,
timeout: 305,
timeout: 5,
},
description: 'settings',
})
@@ -195,4 +197,22 @@ describe('variable-modal', () => {
description: '',
})
})
it('should keep the number input empty while editing after the user clears it', async () => {
const user = userEvent.setup()
renderVariableModal({
chatVar: {
id: 'var-4',
name: 'timeout',
description: '',
value_type: ChatVarType.Number,
value: 3,
},
})
const input = screen.getByDisplayValue('3') as HTMLInputElement
await user.clear(input)
expect(input.value).toBe('')
})
})
@@ -108,7 +108,7 @@ export const useVariableModalState = ({
if (prev.type === ChatVarType.Object) {
if (nextEditInJSON) {
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
const nextValue = prev.objectValue.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined
nextState.value = nextValue
nextState.editorContent = JSON.stringify(nextValue)
return nextState
@@ -133,8 +133,11 @@ export const useVariableModalState = ({
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
if (nextEditInJSON) {
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
? prev.value.filter(Boolean)
const compactValues = Array.isArray(prev.value)
? prev.value.filter(item => item !== null && item !== undefined && item !== '')
: []
const nextValue = compactValues.length
? compactValues
: undefined
nextState.value = nextValue
if (!prev.editorContent)
@@ -181,12 +184,15 @@ export const useVariableModalState = ({
return
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
notify({ type: 'error', message: 'name is existed' })
notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }),
})
return
}
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
notify({ type: 'error', message: 'object key can not be empty' })
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && item.value !== undefined && item.value !== '')) {
notify({ type: 'error', message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }) })
return
}
@@ -72,7 +72,7 @@ export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectVal
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value
return acc
}, {})
}
@@ -88,6 +88,8 @@ export const formatChatVariableValue = ({
type: ChatVarType
value: unknown
}) => {
const compactArrayValue = (items: unknown[]) =>
items.filter(item => item !== null && item !== undefined && item !== '')
switch (type) {
case ChatVarTypeEnum.String:
return value || ''
@@ -100,7 +102,7 @@ export const formatChatVariableValue = ({
case ChatVarTypeEnum.ArrayString:
case ChatVarTypeEnum.ArrayNumber:
case ChatVarTypeEnum.ArrayObject:
return Array.isArray(value) ? value.filter(Boolean) : []
return Array.isArray(value) ? compactArrayValue(value) : []
case ChatVarTypeEnum.ArrayBoolean:
return value || []
}
@@ -151,6 +153,8 @@ export const parseEditorContent = ({
if (type !== ChatVarTypeEnum.ArrayBoolean)
return parsed
if (!Array.isArray(parsed))
throw new TypeError('ArrayBoolean editor content must be a JSON array')
return parsed
.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)
@@ -138,7 +138,10 @@ export const ValueSection = ({
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value as number | undefined}
onChange={e => onArrayChange([Number(e.target.value)])}
onChange={(e) => {
const rawValue = e.target.value
onArrayChange([rawValue === '' ? undefined : Number(rawValue)])
}}
type="number"
/>
)}
+1 -4
View File
@@ -6416,11 +6416,8 @@
}
},
"app/components/workflow-app/hooks/use-workflow-run.ts": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 13
"count": 5
}
},
"app/components/workflow-app/hooks/use-workflow-template.ts": {
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.13.2",
"version": "1.13.3",
"private": true,
"packageManager": "pnpm@10.32.1",
"imports": {
@@ -125,15 +125,15 @@
"mime": "4.1.0",
"mitt": "3.0.1",
"negotiator": "1.0.0",
"next": "16.2.1",
"next": "16.2.3",
"next-themes": "0.4.6",
"nuqs": "2.8.9",
"pinyin-pro": "3.28.0",
"qrcode.react": "4.2.0",
"qs": "6.15.0",
"react": "19.2.4",
"react": "19.2.5",
"react-18-input-autosize": "3.0.0",
"react-dom": "19.2.4",
"react-dom": "19.2.5",
"react-easy-crop": "5.5.6",
"react-hotkeys-hook": "5.2.4",
"react-i18next": "16.6.1",
@@ -173,8 +173,8 @@
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@mdx-js/rollup": "3.1.1",
"@next/eslint-plugin-next": "16.2.1",
"@next/mdx": "16.2.1",
"@next/eslint-plugin-next": "16.2.3",
"@next/mdx": "16.2.3",
"@rgrove/parse-xml": "4.2.0",
"@storybook/addon-docs": "10.3.1",
"@storybook/addon-links": "10.3.1",
@@ -231,7 +231,7 @@
"nock": "14.0.11",
"postcss": "8.5.8",
"postcss-js": "5.1.0",
"react-server-dom-webpack": "19.2.4",
"react-server-dom-webpack": "19.2.5",
"sass": "1.98.0",
"storybook": "10.3.1",
"tailwindcss": "3.4.19",
+533 -533
View File
File diff suppressed because it is too large Load Diff