Compare commits

..
Author SHA1 Message Date
zhaohao1004 784dd3a555 Optimize expired message cleanup batching 2026-06-17 19:53:12 +08:00
yyhandGitHub 8782da42c8 fix(web): prevent workspace trigger focus ring clipping (#37576) 2026-06-17 08:33:51 +00:00
非法操作andGitHub e6a91bfcde chore: workflow restore sandbox upgrade (#37568) 2026-06-17 08:13:26 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
912c0fa8d1 fix(agent): add agent app duplicate endpoint (#37571)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 08:09:47 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
872b5a081f chore(i18n): sync translations with en-US (#37557)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <[email protected]>
2026-06-17 06:56:40 +00:00
zyssyz123andGitHub 758bea1a91 fix(api): hide agent apps from installed apps (#37570) 2026-06-17 06:47:39 +00:00
zyssyz123andGitHub 3b0f6aef8e fix(api): allow inline workflow agent soul saves (#37563) 2026-06-17 06:02:03 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f203ab7f1d fix(agent-v2): include workflow references in agent list (#37567)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 05:56:53 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e970cbde0f feat: add agent roster observability APIs (#37566)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 05:22:52 +00:00
Prajeeth ChannaandGitHub f992ede836 test: replace logger patch with caplog in remaining test files (#37562) 2026-06-17 03:37:09 +00:00
Ingram ZGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6ab5cf109b refactor: optimize free plan workflow run cleanup batching (#37227)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 03:19:26 +00:00
EvanGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
8ca8b3d59a refactor: replace mock.patch logger with pytest caplog in tests (#37560)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 02:22:39 +00:00
Prajeeth ChannaandGitHub 3f81ec1212 test: replace logger patch with caplog in version and rag pipeline tests (#37554) 2026-06-17 01:50:50 +00:00
JingyiandGitHub e189ceb397 fix: align app and knowledge detail shell styles (#37555) 2026-06-17 01:25:05 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
bacc48d16e fix(agent): align config detail and output contracts (#37535)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 01:17:20 +00:00
zyssyz123andGitHub 7cb4a30040 fix(agent): persist Agent App prompt message (#37534) 2026-06-16 16:44:06 +00:00
frankandGitHub 56dce93524 test: replace logger patch with caplog in test_telemetry_log (#37533) 2026-06-16 14:35:15 +00:00
zyssyz123andGitHub 813a1677b2 fix: support Agent v2 plugin tool runtime params (#37526) 2026-06-16 11:03:55 +00:00
KVOJJJinGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1427b0b098 feat: refine snippet layout (#37517)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-16 09:47:38 +00:00
yyhandGitHub 2893adf5e4 test(dify-ui): add Storybook interaction coverage (#37519) 2026-06-16 09:39:37 +00:00
-LAN-andGitHub eb2aaf2ac1 fix(docker): remove duplicate inline styles env (#37510) 2026-06-16 09:36:48 +00:00
143 changed files with 5601 additions and 1282 deletions
+12
View File
@@ -1460,6 +1460,18 @@ class SandboxExpiredRecordsCleanConfig(BaseSettings):
description="Maximum number of records to process in each batch",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_SIZE: PositiveInt = Field(
description="Initial number of message candidates to scan in each expired records cleanup batch",
default=10000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_MAX_SIZE: PositiveInt = Field(
description="Maximum number of message candidates to scan in each expired records cleanup batch",
default=50000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_DELETE_BATCH_SIZE: PositiveInt = Field(
description="Maximum number of records to delete in each expired records cleanup transaction",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: PositiveInt = Field(
description="Maximum interval in milliseconds between batches",
default=200,
+212 -9
View File
@@ -1,15 +1,18 @@
from uuid import UUID
from flask import request
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.app import (
AppDetailWithSite,
AppListQuery,
AppPagination,
AppPartial,
CopyAppPayload,
UpdateAppPayload,
_normalize_app_list_query_args,
)
@@ -27,14 +30,22 @@ from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
)
from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
AgentObservabilityService,
AgentStatisticsQueryParams,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
@@ -63,11 +74,71 @@ class AgentAppUpdatePayload(UpdateAppPayload):
role: str | None = Field(default=None, description="Agent role", max_length=255)
class AgentAppPublishedReferenceResponse(BaseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
class AgentAppPartial(AppPartial):
published_reference_count: int = 0
published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list)
class AgentAppPagination(BaseModel):
page: int
limit: int
total: int
has_more: bool
data: list[AgentAppPartial]
class AgentLogsQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, le=100, description="Page size")
keyword: str | None = Field(default=None, description="Search query, answer, or conversation name")
status: str | None = Field(default=None, description="Filter by success, failed, or paused")
source: str | None = Field(
default=None,
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
)
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
@field_validator("keyword", "status", "source", "start", "end", mode="before")
@classmethod
def empty_string_to_none(cls, value: str | None) -> str | None:
if value == "":
return None
return value
class AgentStatisticsQuery(BaseModel):
source: str | None = Field(
default=None,
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
)
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
@field_validator("source", "start", "end", mode="before")
@classmethod
def empty_string_to_none(cls, value: str | None) -> str | None:
if value == "":
return None
return value
register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
CopyAppPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
AgentStatisticsQuery,
AgentIdPath,
AppListQuery,
UpdateAppPayload,
@@ -76,12 +147,15 @@ register_schema_models(
register_response_schema_models(
console_ns,
AppDetailWithSite,
AppPagination,
AgentAppPagination,
AgentAppPublishedReferenceResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
)
@@ -95,7 +169,8 @@ def _serialize_agent_app_detail(app_model) -> dict:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
agent = _agent_roster_service().get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
roster_service = _agent_roster_service()
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
if not agent:
raise AgentNotFoundError()
payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
@@ -103,15 +178,28 @@ def _serialize_agent_app_detail(app_model) -> dict:
payload["app_id"] = str(app_model.id)
payload["id"] = agent.id
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
agent=agent,
)
return payload
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
app_ids = [str(app.id) for app in app_pagination.items]
agents_by_app_id = _agent_roster_service().load_app_backing_agents_by_app_id(
roster_service = _agent_roster_service()
agents_by_app_id = roster_service.load_app_backing_agents_by_app_id(
tenant_id=tenant_id,
app_ids=app_ids,
)
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
)
published_references_by_agent_id = roster_service.load_published_references_by_agent_id(
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()],
)
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
@@ -121,17 +209,45 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
item["app_id"] = app_id
item["id"] = agent.id
item["role"] = agent.role or ""
return payload
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
published_references = published_references_by_agent_id.get(agent.id, [])
item["published_reference_count"] = len(published_references)
item["published_references"] = [
{
"app_id": reference["app_id"],
"app_name": reference["app_name"],
"app_icon_type": reference["app_icon_type"],
"app_icon": reference["app_icon"],
"app_icon_background": reference["app_icon_background"],
}
for reference in published_references
]
return AgentAppPagination.model_validate(payload).model_dump(
mode="json",
exclude={"data": {"__all__": {"bound_agent_id"}}},
)
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def _agent_observability_service() -> AgentObservabilityService:
return AgentObservabilityService(db.session)
def _parse_observability_time_range(start: str | None, end: str | None, account: Account):
timezone = account.timezone or "UTC"
try:
return parse_time_range(start, end, timezone)
except ValueError as exc:
abort(400, description=str(exc))
@console_ns.route("/agent")
class AgentAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__])
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -152,7 +268,7 @@ class AgentAppListApi(Resource):
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params)
if app_pagination is None:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
@@ -234,6 +350,34 @@ class AgentAppApi(Resource):
return "", 204
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = CopyAppPayload.model_validate(console_ns.payload or {})
copied_app = _agent_roster_service().duplicate_agent_app(
tenant_id=tenant_id,
agent_id=str(agent_id),
account=current_user,
name=args.name,
description=args.description,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
return _serialize_agent_app_detail(copied_app), 201
@console_ns.route("/agent/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@@ -256,6 +400,65 @@ class AgentInviteOptionsApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/logs")
class AgentLogsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
@console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
start, end = _parse_observability_time_range(query.start, query.end, current_user)
try:
payload = _agent_observability_service().list_logs(
app=app_model,
params=AgentLogQueryParams(
page=query.page,
limit=query.limit,
keyword=query.keyword,
status=query.status,
source=query.source,
start=start,
end=end,
),
)
except ValueError as exc:
abort(400, description=str(exc))
return dump_response(AgentLogListResponse, payload)
@console_ns.route("/agent/<uuid:agent_id>/statistics/summary")
class AgentStatisticsSummaryApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentStatisticsQuery))
@console_ns.response(
200,
"Agent monitoring summary and chart data",
console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
timezone = current_user.timezone or "UTC"
start, end = _parse_observability_time_range(query.start, query.end, current_user)
try:
payload = _agent_observability_service().get_statistics_summary(
app=app_model,
params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone),
)
except ValueError as exc:
abort(400, description=str(exc))
return dump_response(AgentStatisticSummaryEnvelopeResponse, payload)
@console_ns.route("/agent/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
+2
View File
@@ -403,6 +403,7 @@ class AppPartial(ResponseModel):
# For Agent App responses exposed through /agent.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@@ -457,6 +458,7 @@ class AppDetailWithSite(AppDetail):
# For Agent App responses exposed through /agent.
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@@ -73,9 +73,12 @@ def _published_app_filter():
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
+8 -3
View File
@@ -373,7 +373,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
app_config = application_generate_entity.app_config
model_name = application_generate_entity.model_conf.model
query = application_generate_entity.query
query = application_generate_entity.query or ""
# content moderation (sensitive_word_avoidance); a blocked input yields a
# preset answer, an "overridden" action returns a sanitized query.
@@ -388,7 +388,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=application_generate_entity.trace_manager,
)
except ModerationError as e:
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e))
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query)
return True, query
# annotation reply: a matching annotation answers the turn deterministically.
@@ -405,7 +405,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id),
PublishFrom.APPLICATION_MANAGER,
)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content)
publish_text_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=annotation_reply.content,
user_query=query,
)
return True, query
return False, query
+25 -7
View File
@@ -46,13 +46,25 @@ from core.repositories.human_input_repository import HumanInputFormRepository, H
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
from models.agent_config_entities import AgentSoulConfig
logger = logging.getLogger(__name__)
def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
if not user_query:
return []
return [UserPromptMessage(content=user_query)]
def publish_text_answer(
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
user_query: str | None = None,
) -> None:
"""Publish a complete assistant answer as one chunk + message-end.
The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed
@@ -60,9 +72,10 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ
both the backend-produced answer and short-circuited answers (moderation /
annotation reply) share the exact same persistence + SSE path.
"""
prompt_messages = _prompt_messages_from_query(user_query)
chunk = LLMResultChunk(
model=model_name,
prompt_messages=[],
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
)
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
@@ -70,7 +83,7 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ
QueueMessageEndEvent(
llm_result=LLMResult(
model=model_name,
prompt_messages=[],
prompt_messages=prompt_messages,
message=AssistantPromptMessage(content=answer),
usage=LLMUsage.empty_usage(),
),
@@ -153,6 +166,7 @@ class AgentAppRunner:
model_name=model_name,
runtime=runtime,
queue_manager=queue_manager,
query=query,
)
return
@@ -161,7 +175,7 @@ class AgentAppRunner:
raise AgentBackendError(str(error))
answer = self._extract_answer(terminal.output)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query)
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
@@ -181,6 +195,7 @@ class AgentAppRunner:
model_name: str,
runtime: AgentAppRuntimeRequest,
queue_manager: AppQueueManager,
query: str,
) -> None:
"""End the chat turn on a dify.ask_human call: create a conversation-owned
HITL form, persist the pause correlation, and surface the question."""
@@ -214,6 +229,7 @@ class AgentAppRunner:
queue_manager=queue_manager,
model_name=model_name,
answer=self._ask_human_message(created.args),
query=query,
)
def _resolve_pending_ask_human(
@@ -287,10 +303,12 @@ class AgentAppRunner:
except Exception:
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
def _publish_answer(
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None
) -> None:
# MVP: emit the full answer as a single chunk + message-end. The chat
# task pipeline streams the chunk over SSE and persists the message.
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query)
def _save_session(
self,
+24 -2
View File
@@ -398,6 +398,8 @@ class ToolManager:
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: "VariablePool | None" = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool:
"""
get the agent tool runtime
@@ -415,7 +417,12 @@ class ToolManager:
runtime_parameters: dict[str, Any] = {}
parameters = tool_entity.get_merged_runtime_parameters()
runtime_parameters = cls._convert_tool_parameters_type(
parameters, variable_pool, agent_tool.tool_parameters, typ="agent"
parameters,
variable_pool,
agent_tool.tool_parameters,
typ="agent",
allow_file_parameters=allow_file_parameters,
use_default_for_missing_form_parameters=use_default_for_missing_form_parameters,
)
# decrypt runtime parameters
encryption_manager = ToolParameterConfigurationManager(
@@ -1063,6 +1070,8 @@ class ToolManager:
variable_pool: "VariablePool | None",
tool_configurations: Mapping[str, Any],
typ: Literal["agent", "workflow", "tool"] = "workflow",
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> dict[str, Any]:
"""
Convert tool parameters type
@@ -1081,6 +1090,7 @@ class ToolManager:
}
and parameter.required
and typ == "agent"
and not allow_file_parameters
):
raise ValueError(f"file type parameter {parameter.name} not supported in agent")
# save tool parameter to tool entity memory
@@ -1117,7 +1127,19 @@ class ToolManager:
runtime_parameters[parameter.name] = parameter_value
else:
value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name))
parameter_value = tool_configurations.get(parameter.name)
if use_default_for_missing_form_parameters and parameter_value is None:
if parameter.default is not None:
parameter_value = parameter.default
elif (
parameter.required
and parameter.type == ToolParameter.ToolParameterType.SELECT
and parameter.options
):
parameter_value = parameter.options[0].value
else:
continue
value = parameter.init_frontend_parameter(parameter_value)
runtime_parameters[parameter.name] = value
return runtime_parameters
@@ -42,6 +42,8 @@ class AgentToolRuntimeProvider(Protocol):
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Any | None = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool: ...
@@ -176,6 +178,8 @@ class WorkflowAgentPluginToolsBuilder:
user_id=user_id,
invoke_from=invoke_from,
variable_pool=None,
allow_file_parameters=True,
use_default_for_missing_form_parameters=True,
)
except ToolProviderNotFoundError as exc:
raise WorkflowAgentPluginToolsBuildError(
@@ -42,6 +42,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
@@ -395,7 +396,11 @@ class WorkflowAgentRuntimeRequestBuilder:
@staticmethod
def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]:
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item)
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
output.type,
array_item=output.array_item,
children=output.children,
)
if output.description:
schema["description"] = output.description
return schema
@@ -405,6 +410,7 @@ class WorkflowAgentRuntimeRequestBuilder:
output_type: DeclaredOutputType,
*,
array_item: DeclaredArrayItem | None = None,
children: Sequence[DeclaredOutputChildConfig] | None = None,
) -> dict[str, Any]:
match output_type:
case DeclaredOutputType.STRING:
@@ -414,18 +420,23 @@ class WorkflowAgentRuntimeRequestBuilder:
case DeclaredOutputType.BOOLEAN:
return {"type": "boolean"}
case DeclaredOutputType.OBJECT:
return {"type": "object"}
object_schema: dict[str, Any] = {"type": "object"}
WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or [])
return object_schema
case DeclaredOutputType.ARRAY:
# Stage 4 §4.2: items shape mirrors the declared array_item.
# Validator guarantees array_item is set when type is array.
item_type = array_item.type if array_item else DeclaredOutputType.OBJECT
schema: dict[str, Any] = {
array_schema: dict[str, Any] = {
"type": "array",
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type),
"items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(
item_type,
children=array_item.children if array_item else None,
),
}
if array_item is not None and array_item.description:
schema["items"]["description"] = array_item.description
return schema
array_schema["items"]["description"] = array_item.description
return array_schema
case DeclaredOutputType.FILE:
return {
"oneOf": [
@@ -469,6 +480,27 @@ class WorkflowAgentRuntimeRequestBuilder:
}
assert_never(output_type)
@staticmethod
def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None:
if not children:
return
properties: dict[str, Any] = {}
required: list[str] = []
for child in children:
child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(
child.type,
array_item=child.array_item,
children=child.children,
)
if child.description:
child_schema["description"] = child.description
properties[child.name] = child_schema
if child.required:
required.append(child.name)
schema["properties"] = properties
if required:
schema["required"] = required
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
normalized: dict[str, str | int | float | bool | None] = {}
@@ -669,7 +701,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None:
name = _name_from_mapping(data)
if name is None:
return None
ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id")
ref = (
data.get("ref")
or data.get("value")
or data.get("id")
or data.get("credential_id")
or data.get("provider_credential_id")
)
return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None)
+112 -1
View File
@@ -1,8 +1,10 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import Field
from pydantic import Field, field_validator
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.agent import (
AgentConfigRevisionOperation,
AgentIconType,
@@ -70,6 +72,7 @@ class AgentRosterResponse(ResponseModel):
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
active_config_is_published: bool = False
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None
@@ -104,6 +107,114 @@ class AgentInviteOptionsResponse(ResponseModel):
has_more: bool
class AgentLogItemResponse(ResponseModel):
id: str
message_id: str
conversation_id: str
conversation_name: str | None = None
query: str
answer: str
status: str
error: str | None = None
source: str | None = None
from_source: str | None = None
from_end_user_id: str | None = None
from_account_id: str | None = None
message_tokens: int
answer_tokens: int
total_tokens: int
total_price: str
currency: str
latency: float
created_at: int | None = None
updated_at: int | None = None
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class AgentLogListResponse(ResponseModel):
data: list[AgentLogItemResponse]
page: int
limit: int
total: int
has_more: bool
class AgentStatisticSummaryResponse(ResponseModel):
total_messages: int
total_conversations: int
total_end_users: int
total_tokens: int
total_price: str
currency: str
average_session_interactions: float
average_response_time: float
tokens_per_second: float
user_satisfaction_rate: float
class AgentDailyMessageStatisticResponse(ResponseModel):
date: str
message_count: int
class AgentDailyConversationStatisticResponse(ResponseModel):
date: str
conversation_count: int
class AgentDailyEndUserStatisticResponse(ResponseModel):
date: str
terminal_count: int
class AgentTokenUsageStatisticResponse(ResponseModel):
date: str
token_count: int
total_price: str
currency: str
class AgentAverageSessionInteractionStatisticResponse(ResponseModel):
date: str
interactions: float
class AgentAverageResponseTimeStatisticResponse(ResponseModel):
date: str
latency: float
class AgentTokensPerSecondStatisticResponse(ResponseModel):
date: str
tps: float
class AgentUserSatisfactionRateStatisticResponse(ResponseModel):
date: str
rate: float
class AgentStatisticChartsResponse(ResponseModel):
daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list)
daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list)
daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list)
token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list)
average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list)
average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list)
tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list)
user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list)
class AgentStatisticSummaryEnvelopeResponse(ResponseModel):
source: str
summary: AgentStatisticSummaryResponse
charts: AgentStatisticChartsResponse
class AgentConfigRevisionResponse(ResponseModel):
id: str
previous_snapshot_id: str | None = None
+91 -2
View File
@@ -2,9 +2,9 @@ from __future__ import annotations
import re
from enum import StrEnum
from typing import Any, Final, Literal
from typing import Annotated, Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
@@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum):
FILE = "file"
_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"required": {"type": "boolean"},
"file": {"type": "object", "additionalProperties": True},
"array_item": {
"type": "object",
"additionalProperties": True,
"properties": {
"type": {
"type": "string",
"enum": [item.value for item in DeclaredOutputType],
},
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
},
"children": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
},
"required": ["name", "type"],
},
}
DeclaredOutputChildren = Annotated[
list["DeclaredOutputChildConfig"],
WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA),
]
class AgentCliToolAuthorizationStatus(StrEnum):
"""Authorization state for Agent-scoped CLI tools.
@@ -148,6 +186,9 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
# UI-facing selected secret reference. This is a credential/ref id, not the
# plaintext secret value; runtime maps it to the shell-layer ``ref``.
value: str | None = Field(default=None, max_length=255)
id: str | None = Field(default=None, max_length=255)
ref: str | None = Field(default=None, max_length=255)
credential_id: str | None = Field(default=None, max_length=255)
@@ -507,11 +548,55 @@ class DeclaredArrayItem(BaseModel):
type: DeclaredOutputType
description: str | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _reject_nested_array(self) -> DeclaredArrayItem:
if self.type == DeclaredOutputType.ARRAY:
raise ValueError("nested arrays are not supported as array_item.type")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("array_item.children is only allowed when array_item.type is object")
return self
class DeclaredOutputChildConfig(BaseModel):
"""Nested field under an object-shaped declared output.
The first backend version keeps child fields lightweight: they describe the
variable-picker/schema tree but do not own independent retry/check behavior.
"""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
type: DeclaredOutputType
description: str | None = None
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
@model_validator(mode="after")
def _validate_shape(self) -> DeclaredOutputChildConfig:
if not _OUTPUT_NAME_PATTERN.fullmatch(self.name):
raise ValueError(
f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} "
"(JSON-schema-friendly identifier)"
)
if self.type == DeclaredOutputType.FILE:
if self.file is None:
self.file = DeclaredOutputFileConfig()
elif self.file is not None:
raise ValueError("file metadata is only allowed for file output children")
if self.type == DeclaredOutputType.ARRAY:
if self.array_item is None:
self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT)
elif self.array_item is not None:
raise ValueError("array_item is only allowed when child type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object output children")
return self
@@ -592,6 +677,7 @@ class DeclaredOutputConfig(BaseModel):
required: bool = True
file: DeclaredOutputFileConfig | None = None
array_item: DeclaredArrayItem | None = None
children: DeclaredOutputChildren = Field(default_factory=list)
check: DeclaredOutputCheckConfig | None = None
failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy)
@@ -625,6 +711,9 @@ class DeclaredOutputConfig(BaseModel):
elif self.array_item is not None:
raise ValueError("array_item is only allowed when type is array")
if self.children and self.type != DeclaredOutputType.OBJECT:
raise ValueError("children is only allowed for object outputs")
# Per PRD §OUTPUT 配置框: output check is file-only.
if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE:
raise ValueError("output check is only allowed for file outputs")
+273 -8
View File
@@ -311,7 +311,7 @@ Check if activation token is valid
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app list | **application/json**: [AppPagination](#apppagination)<br> |
| 200 | Agent app list | **application/json**: [AgentAppPagination](#agentapppagination)<br> |
### [POST] /agent
#### Request Body
@@ -508,6 +508,27 @@ Stop a running Agent App chat message generation
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)<br> |
### [POST] /agent/{agent_id}/copy
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [CopyAppPayload](#copyapppayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent app copied successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)<br> |
| 400 | Invalid request parameters | |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/drive/files
List agent drive entries for an Agent App
@@ -638,6 +659,26 @@ Commit an uploaded file into the Agent App drive under files/<name>
| ---- | ----------- | ------ |
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
### [GET] /agent/{agent_id}/logs
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| end | query | End date (YYYY-MM-DD HH:MM) | No | string |
| keyword | query | Search query, answer, or conversation name | No | string |
| limit | query | Page size | No | integer, <br>**Default:** 20 |
| page | query | Page number | No | integer, <br>**Default:** 1 |
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
| status | query | Filter by success, failed, or paused | No | string |
| agent_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)<br> |
### [GET] /agent/{agent_id}/messages/{message_id}
Get Agent App message details by ID
@@ -790,6 +831,22 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill
| ---- | ----------- | ------ |
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
### [GET] /agent/{agent_id}/statistics/summary
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| end | query | End date (YYYY-MM-DD HH:MM) | No | string |
| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string |
| start | query | Start date (YYYY-MM-DD HH:MM) | No | string |
| agent_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)<br> |
### [GET] /agent/{agent_id}/versions
#### Parameters
@@ -11305,6 +11362,59 @@ default (the config form sends the full desired feature state on save).
| suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No |
| text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No |
#### AgentAppPagination
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentAppPartial](#agentapppartial) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentAppPartial
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_mode | string | | No |
| active_config_is_published | boolean | | No |
| app_id | string | | No |
| author_name | string | | No |
| bound_agent_id | string | | No |
| create_user_name | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | No |
| has_draft_trigger | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| id | string | | Yes |
| is_starred | boolean | | No |
| max_active_requests | integer | | No |
| mode | string | | Yes |
| model_config | [ModelConfigPartial](#modelconfigpartial) | | No |
| name | string | | Yes |
| published_reference_count | integer | | No |
| published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No |
| role | string | | No |
| tags | [ [Tag](#tag) ] | | No |
| updated_at | integer | | No |
| updated_by | string | | No |
| use_icon_as_answer_icon | boolean | | No |
| workflow | [WorkflowPartial](#workflowpartial) | | No |
#### AgentAppPublishedReferenceResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_icon | string | | No |
| app_icon_background | string | | No |
| app_icon_type | string | | No |
| app_id | string | | Yes |
| app_name | string | | Yes |
#### AgentAppUpdatePayload
| Name | Type | Description | Required |
@@ -11318,6 +11428,20 @@ default (the config form sends the full desired feature state on save).
| role | string | Agent role | No |
| use_icon_as_answer_icon | boolean | Use icon as answer icon | No |
#### AgentAverageResponseTimeStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| latency | number | | Yes |
#### AgentAverageSessionInteractionStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| interactions | number | | Yes |
#### AgentCliToolAuthorizationStatus
Authorization state for Agent-scoped CLI tools.
@@ -11558,6 +11682,27 @@ Audit operation recorded for Agent Soul version/revision changes.
| version | integer | | Yes |
| version_note | string | | No |
#### AgentDailyConversationStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conversation_count | integer | | Yes |
| date | string | | Yes |
#### AgentDailyEndUserStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| terminal_count | integer | | Yes |
#### AgentDailyMessageStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| message_count | integer | | Yes |
#### AgentDriveDeleteFileByAgentQuery
| Name | Type | Description | Required |
@@ -11703,6 +11848,7 @@ Supported icon storage formats for Agent roster entries.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_is_published | boolean | | No |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
@@ -11796,6 +11942,41 @@ the current roster/workflow APIs scoped to Dify Agent.
| ---- | ---- | ----------- | -------- |
| AgentKnowledgeQueryMode | string | | |
#### AgentLogItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| answer | string | | Yes |
| answer_tokens | integer | | Yes |
| conversation_id | string | | Yes |
| conversation_name | string | | No |
| created_at | integer | | No |
| currency | string | | Yes |
| error | string | | No |
| from_account_id | string | | No |
| from_end_user_id | string | | No |
| from_source | string | | No |
| id | string | | Yes |
| latency | number | | Yes |
| message_id | string | | Yes |
| message_tokens | integer | | Yes |
| query | string | | Yes |
| source | string | | No |
| status | string | | Yes |
| total_price | string | | Yes |
| total_tokens | integer | | Yes |
| updated_at | integer | | No |
#### AgentLogListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentLogItemResponse](#agentlogitemresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentLogMetaResponse
| Name | Type | Description | Required |
@@ -11823,6 +12004,18 @@ the current roster/workflow APIs scoped to Dify Agent.
| iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes |
| meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes |
#### AgentLogsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| end | string | End date (YYYY-MM-DD HH:MM) | No |
| keyword | string | Search query, answer, or conversation name | No |
| limit | integer, <br>**Default:** 20 | Page size | No |
| page | integer, <br>**Default:** 1 | Page number | No |
| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No |
| start | string | Start date (YYYY-MM-DD HH:MM) | No |
| status | string | Filter by success, failed, or paused | No |
#### AgentMemoryArtifactConfig
| Name | Type | Description | Required |
@@ -11924,6 +12117,7 @@ the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_is_published | boolean | | No |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
@@ -11989,6 +12183,7 @@ Visibility and lifecycle scope of an Agent record.
| provider_credential_id | string | | No |
| ref | string | | No |
| type | string | | No |
| value | string | | No |
| variable | string | | No |
#### AgentSensitiveWordAvoidanceFeatureConfig
@@ -12194,6 +12389,50 @@ Origin that created or imported the Agent.
| ---- | ---- | ----------- | -------- |
| AgentSource | string | Origin that created or imported the Agent. | |
#### AgentStatisticChartsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No |
| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No |
| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No |
| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No |
| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No |
| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No |
| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No |
| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No |
#### AgentStatisticSummaryEnvelopeResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes |
| source | string | | Yes |
| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes |
#### AgentStatisticSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| average_response_time | number | | Yes |
| average_session_interactions | number | | Yes |
| currency | string | | Yes |
| tokens_per_second | number | | Yes |
| total_conversations | integer | | Yes |
| total_end_users | integer | | Yes |
| total_messages | integer | | Yes |
| total_price | string | | Yes |
| total_tokens | integer | | Yes |
| user_satisfaction_rate | number | | Yes |
#### AgentStatisticsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| end | string | End date (YYYY-MM-DD HH:MM) | No |
| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No |
| start | string | Start date (YYYY-MM-DD HH:MM) | No |
#### AgentStatus
Soft lifecycle state for Agent records.
@@ -12236,6 +12475,22 @@ Soft lifecycle state for Agent records.
| tool_input | string | | No |
| tool_labels | [JSONValue](#jsonvalue) | | Yes |
#### AgentTokenUsageStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| currency | string | | Yes |
| date | string | | Yes |
| token_count | integer | | Yes |
| total_price | string | | Yes |
#### AgentTokensPerSecondStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| tps | number | | Yes |
#### AgentToolCallResponse
| Name | Type | Description | Required |
@@ -12250,6 +12505,13 @@ Soft lifecycle state for Agent records.
| tool_output | object | | Yes |
| tool_parameters | object | | Yes |
#### AgentUserSatisfactionRateStatisticResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| date | string | | Yes |
| rate | number | | Yes |
#### AllowedExtensionsResponse
| Name | Type | Description | Required |
@@ -12515,6 +12777,7 @@ Enum class for api provider schema type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_mode | string | | No |
| active_config_is_published | boolean | | No |
| api_base_url | string | | No |
| app_id | string | | No |
| bound_agent_id | string | | No |
@@ -12627,10 +12890,10 @@ AppMCPServer Status Enum
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AppPartial](#apppartial) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| has_next | boolean | | Yes |
| items | [ [AppPartial](#apppartial) ] | | Yes |
| page | integer | | Yes |
| per_page | integer | | Yes |
| total | integer | | Yes |
#### AppPartial
@@ -12638,23 +12901,23 @@ AppMCPServer Status Enum
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_mode | string | | No |
| active_config_is_published | boolean | | No |
| app_id | string | | No |
| app_model_config | [ModelConfigPartial](#modelconfigpartial) | | No |
| author_name | string | | No |
| bound_agent_id | string | | No |
| create_user_name | string | | No |
| created_at | integer | | No |
| created_by | string | | No |
| description | string | | No |
| desc_or_prompt | string | | No |
| has_draft_trigger | boolean | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| id | string | | Yes |
| is_starred | boolean | | No |
| max_active_requests | integer | | No |
| mode | string | | Yes |
| model_config | [ModelConfigPartial](#modelconfigpartial) | | No |
| mode_compatible_with_agent | string | | Yes |
| name | string | | Yes |
| role | string | | No |
| tags | [ [Tag](#tag) ] | | No |
@@ -14201,6 +14464,7 @@ about. Stage 4 §4.2.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| description | string | | No |
| type | [DeclaredOutputType](#declaredoutputtype) | | Yes |
@@ -14229,6 +14493,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards.
| ---- | ---- | ----------- | -------- |
| array_item | [DeclaredArrayItem](#declaredarrayitem) | | No |
| check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No |
| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| description | string | | No |
| failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No |
| file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No |
@@ -35,6 +35,7 @@ Example:
"""
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, TypedDict
@@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict):
pause_reasons: int
@dataclass(frozen=True)
class WorkflowRunCleanupRef:
"""
Lightweight workflow run reference for retention cleanup scans.
Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the
query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped
after billing checks.
"""
id: str
tenant_id: str
created_at: datetime
class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
Protocol for service-layer WorkflowRun repository operations.
@@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime | None,
end_before: datetime,
last_seen: tuple[datetime, str] | None,
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
upper_bound: tuple[datetime, str] | None = None,
) -> Sequence[WorkflowRunCleanupRef]:
"""
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
Args:
start_from: Optional inclusive lower time boundary.
end_before: Exclusive upper time boundary.
last_seen: Optional exclusive `(created_at, id)` cursor lower bound.
batch_size: Maximum number of refs to return.
run_types: Optional workflow type filter.
tenant_ids: Optional tenant filter.
workflow_ids: Optional workflow ID filter.
upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second,
tenant-filtered target query that must stay within the candidate page high-water cursor.
Returns:
Ordered lightweight cleanup refs containing only id, tenant_id, and created_at.
"""
...
def get_archived_run_ids(
self,
session: Session,
@@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def delete_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
"""
Delete workflow runs and cleanup-owned related records by workflow run IDs.
This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models.
"""
...
def get_app_logs_by_run_id(
self,
session: Session,
@@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def count_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
"""
Count workflow runs and cleanup-owned related records by workflow run IDs.
This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models.
"""
...
def create_workflow_pause(
self,
workflow_run_id: str,
@@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold
from models.enums import WorkflowRunTriggeredFrom
from models.human_input import HumanInputForm, HumanInputFormRecipient
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
RunsWithRelatedCountsDict,
WorkflowRunCleanupRef,
)
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.types import (
AverageInteractionStats,
@@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
return session.scalars(stmt).all()
@override
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime | None,
end_before: datetime,
last_seen: tuple[datetime, str] | None,
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
upper_bound: tuple[datetime, str] | None = None,
) -> Sequence[WorkflowRunCleanupRef]:
"""
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second,
tenant-filtered target query stays within the candidate page already checked against billing.
"""
with self._session_maker() as session:
stmt = (
select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at)
.where(
WorkflowRun.created_at < end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
)
.order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc())
.limit(batch_size)
)
if run_types is not None:
if not run_types:
return []
stmt = stmt.where(WorkflowRun.type.in_(run_types))
if start_from:
stmt = stmt.where(WorkflowRun.created_at >= start_from)
if tenant_ids:
stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids))
if workflow_ids:
stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids))
if last_seen:
stmt = stmt.where(
tuple_(WorkflowRun.created_at, WorkflowRun.id)
> tuple_(
sa.literal(last_seen[0], type_=sa.DateTime()),
sa.literal(last_seen[1], type_=WorkflowRun.id.type),
)
)
if upper_bound:
stmt = stmt.where(
tuple_(WorkflowRun.created_at, WorkflowRun.id)
<= tuple_(
sa.literal(upper_bound[0], type_=sa.DateTime()),
sa.literal(upper_bound[1], type_=WorkflowRun.id.type),
)
)
return [
WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
for run_id, tenant_id, created_at in session.execute(stmt).all()
]
@override
def get_archived_run_ids(
self,
@@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"pause_reasons": pause_reasons_deleted,
}
@override
def delete_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
if not run_ids:
return self._empty_runs_with_related_counts()
run_ids = list(run_ids)
with self._session_maker() as session:
if delete_node_executions:
node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids)
else:
node_executions_deleted, offloads_deleted = 0, 0
app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)))
app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0
pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
pause_ids = session.scalars(pause_stmt).all()
pause_reasons_deleted = 0
pauses_deleted = 0
if pause_ids:
pause_reasons_result = session.execute(
delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids))
)
pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0
pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids)))
pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0
trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0
runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids)))
runs_deleted = cast(CursorResult, runs_result).rowcount or 0
session.commit()
return {
"runs": runs_deleted,
"node_executions": node_executions_deleted,
"offloads": offloads_deleted,
"app_logs": app_logs_deleted,
"trigger_logs": trigger_logs_deleted,
"pauses": pauses_deleted,
"pause_reasons": pause_reasons_deleted,
}
@override
def get_app_logs_by_run_id(
self,
@@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"pause_reasons": int(pause_reasons_count),
}
@override
def count_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
if not run_ids:
return self._empty_runs_with_related_counts()
run_ids = list(run_ids)
with self._session_maker() as session:
if count_node_executions:
node_executions_count, offloads_count = count_node_executions(session, run_ids)
else:
node_executions_count, offloads_count = 0, 0
runs_count = (
session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0
)
app_logs_count = (
session.scalar(
select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))
)
or 0
)
pause_ids = session.scalars(
select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
).all()
pauses_count = len(pause_ids)
pause_reasons_count = 0
if pause_ids:
pause_reasons_count = (
session.scalar(
select(func.count())
.select_from(WorkflowPauseReason)
.where(WorkflowPauseReason.pause_id.in_(pause_ids))
)
or 0
)
trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0
return {
"runs": int(runs_count),
"node_executions": node_executions_count,
"offloads": offloads_count,
"app_logs": int(app_logs_count),
"trigger_logs": trigger_logs_count,
"pauses": pauses_count,
"pause_reasons": int(pause_reasons_count),
}
@staticmethod
def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict:
return {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
@override
def create_workflow_pause(
self,
+3 -1
View File
@@ -40,7 +40,9 @@ def clean_messages():
service = MessagesCleanService.from_days(
policy=policy,
days=dify_config.SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS,
batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE,
batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_SIZE,
max_candidate_batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_CANDIDATE_BATCH_MAX_SIZE,
delete_batch_size=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_DELETE_BATCH_SIZE,
)
stats = service.run()
+29 -1
View File
@@ -115,7 +115,15 @@ class AgentComposerService:
and binding is not None
and binding.agent_id
and payload.save_strategy
in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION)
in (
ComposerSaveStrategy.NODE_JOB_ONLY,
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
)
and (
payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
)
):
cls._require_drive_refs_resolved(
tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul
@@ -823,6 +831,26 @@ class AgentComposerService:
node_job = payload.node_job or WorkflowNodeJobConfig()
if binding:
binding.node_job_config = node_job
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
current_snapshot = cls._require_version(
tenant_id=tenant_id,
agent_id=binding.agent_id,
version_id=binding.current_snapshot_id,
)
version = cls._update_current_version(
current_snapshot=current_snapshot,
account_id=account_id,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=payload.version_note,
)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
if agent.scope != AgentScope.WORKFLOW_ONLY:
raise ValueError("Inline workflow agent binding must point to a workflow-only agent")
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
agent.updated_by = account_id
binding.current_snapshot_id = version.id
binding.updated_by = account_id
return binding
+7 -1
View File
@@ -18,6 +18,7 @@ from services.agent.prompt_mentions import (
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
ComposerSaveStrategy,
ComposerVariant,
WorkflowNodeJobConfig,
)
@@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = (
class ComposerConfigValidator:
@classmethod
def validate_save_payload(cls, payload: ComposerSavePayload) -> None:
if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None:
if (
payload.variant == ComposerVariant.WORKFLOW
and payload.soul_lock.locked
and payload.agent_soul is not None
and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
):
raise AgentSoulLockedError()
if payload.agent_soul is not None:
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
import sqlalchemy as sa
from sqlalchemy import func, or_, select
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp
from models.enums import MessageStatus
from models.model import App, Conversation, Message
@dataclass(frozen=True)
class AgentLogQueryParams:
page: int = 1
limit: int = 20
keyword: str | None = None
status: str | None = None
source: str | None = None
start: datetime | None = None
end: datetime | None = None
@dataclass(frozen=True)
class AgentStatisticsQueryParams:
source: str | None = None
start: datetime | None = None
end: datetime | None = None
timezone: str = "UTC"
class AgentObservabilityService:
_SOURCE_ALIASES: dict[str, InvokeFrom] = {
"api": InvokeFrom.SERVICE_API,
"service-api": InvokeFrom.SERVICE_API,
"service_api": InvokeFrom.SERVICE_API,
"console": InvokeFrom.EXPLORE,
"explore": InvokeFrom.EXPLORE,
"explore-app": InvokeFrom.EXPLORE,
"explore_app": InvokeFrom.EXPLORE,
"web": InvokeFrom.WEB_APP,
"web-app": InvokeFrom.WEB_APP,
"web_app": InvokeFrom.WEB_APP,
"debugger": InvokeFrom.DEBUGGER,
"dev": InvokeFrom.DEBUGGER,
"openapi": InvokeFrom.OPENAPI,
"trigger": InvokeFrom.TRIGGER,
}
def __init__(self, session: Any):
self._session = session
@classmethod
def resolve_source(cls, source: str | None) -> InvokeFrom | None:
if not source or source == "all":
return None
normalized = source.strip().lower()
if not normalized or normalized == "all":
return None
try:
return cls._SOURCE_ALIASES[normalized]
except KeyError as exc:
raise ValueError(f"Unsupported source: {source}") from exc
@staticmethod
def _message_status(message: Message) -> str:
if message.error or message.status == MessageStatus.ERROR:
return "failed"
if message.status == MessageStatus.PAUSED:
return "paused"
return "success"
@staticmethod
def _total_tokens(message: Message) -> int:
return int(message.message_tokens or 0) + int(message.answer_tokens or 0)
@classmethod
def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]:
invoke_from = message.invoke_from.value if message.invoke_from else None
return {
"id": message.id,
"message_id": message.id,
"conversation_id": message.conversation_id,
"conversation_name": conversation.name if conversation else None,
"query": message.query,
"answer": message.answer,
"status": cls._message_status(message),
"error": message.error,
"source": invoke_from,
"from_source": message.from_source.value if message.from_source else None,
"from_end_user_id": message.from_end_user_id,
"from_account_id": message.from_account_id,
"message_tokens": int(message.message_tokens or 0),
"answer_tokens": int(message.answer_tokens or 0),
"total_tokens": cls._total_tokens(message),
"total_price": str(message.total_price or Decimal(0)),
"currency": message.currency,
"latency": float(message.provider_response_latency or 0),
"created_at": to_timestamp(message.created_at),
"updated_at": to_timestamp(message.updated_at),
}
def list_logs(self, *, app: App, params: AgentLogQueryParams) -> dict[str, Any]:
source = self.resolve_source(params.source)
stmt = (
select(Message, Conversation)
.join(Conversation, Conversation.id == Message.conversation_id)
.where(Message.app_id == app.id, Conversation.app_id == app.id)
)
stmt = self._apply_source_filter(stmt, source)
if params.start:
stmt = stmt.where(Message.created_at >= params.start)
if params.end:
stmt = stmt.where(Message.created_at < params.end)
if params.keyword:
escaped_keyword = escape_like_pattern(params.keyword)
pattern = f"%{escaped_keyword}%"
stmt = stmt.where(
or_(
Message.query.ilike(pattern, escape="\\"),
Message.answer.ilike(pattern, escape="\\"),
Conversation.name.ilike(pattern, escape="\\"),
)
)
if params.status:
stmt = self._apply_status_filter(stmt, params.status)
total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
rows = list(
self._session.execute(
stmt.order_by(Message.created_at.desc(), Message.id.desc())
.offset((params.page - 1) * params.limit)
.limit(params.limit)
).all()
)
data = []
for message, conversation in rows:
data.append(self.serialize_log_message(message, conversation))
return {
"data": data,
"page": params.page,
"limit": params.limit,
"total": total,
"has_more": params.page * params.limit < total,
}
@classmethod
def _apply_source_filter(cls, stmt, source: InvokeFrom | None):
if source is None:
return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER)
return stmt.where(Message.invoke_from == source)
@staticmethod
def _apply_status_filter(stmt, status: str):
normalized = status.strip().lower()
if normalized in {"success", "normal"}:
return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL)
if normalized in {"failed", "error"}:
return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR))
if normalized == "paused":
return stmt.where(Message.status == MessageStatus.PAUSED)
raise ValueError(f"Unsupported status: {status}")
def get_statistics_summary(self, *, app: App, params: AgentStatisticsQueryParams) -> dict[str, Any]:
source = self.resolve_source(params.source)
rows = self._load_daily_statistics(app=app, params=params, source=source)
charts = self._build_charts(rows)
summary = self._build_summary(rows)
return {
"source": source.value if source else "all",
"summary": summary,
"charts": charts,
}
def _load_daily_statistics(
self, *, app: App, params: AgentStatisticsQueryParams, source: InvokeFrom | None
) -> list[dict[str, Any]]:
converted_created_at = convert_datetime_to_date("m.created_at")
source_condition = "AND m.invoke_from != :debugger" if source is None else "AND m.invoke_from = :source"
sql_query = f"""SELECT
{converted_created_at} AS date,
COUNT(m.id) AS message_count,
COUNT(DISTINCT m.conversation_id) AS conversation_count,
COUNT(DISTINCT m.from_end_user_id) AS end_user_count,
COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count,
COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price,
COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency,
COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum,
COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens,
COUNT(mf.id) AS like_count
FROM messages m
LEFT JOIN message_feedbacks mf
ON mf.message_id = m.id AND mf.rating = 'like'
WHERE
m.app_id = :app_id
{source_condition}"""
args: dict[str, Any] = {
"tz": params.timezone,
"app_id": app.id,
"debugger": InvokeFrom.DEBUGGER,
}
if source is not None:
args["source"] = source
if params.start:
sql_query += " AND m.created_at >= :start"
args["start"] = params.start
if params.end:
sql_query += " AND m.created_at < :end"
args["end"] = params.end
sql_query += " GROUP BY date ORDER BY date"
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
@staticmethod
def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
messages = []
conversations = []
end_users = []
token_usage = []
average_session_interactions = []
average_response_time = []
tokens_per_second = []
user_satisfaction_rate = []
for row in rows:
date = str(row["date"])
message_count = int(row["message_count"] or 0)
conversation_count = int(row["conversation_count"] or 0)
token_count = int(row["token_count"] or 0)
total_price = row["total_price"] or Decimal(0)
avg_latency = float(row["avg_latency"] or 0)
latency_sum = float(row["latency_sum"] or 0)
answer_tokens = int(row["answer_tokens"] or 0)
like_count = int(row["like_count"] or 0)
messages.append({"date": date, "message_count": message_count})
conversations.append({"date": date, "conversation_count": conversation_count})
end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)})
token_usage.append(
{
"date": date,
"token_count": token_count,
"total_price": str(total_price),
"currency": "USD",
}
)
average_session_interactions.append(
{
"date": date,
"interactions": round(message_count / conversation_count, 2) if conversation_count else 0,
}
)
average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)})
tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0})
user_satisfaction_rate.append(
{"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0}
)
return {
"daily_messages": messages,
"daily_conversations": conversations,
"daily_end_users": end_users,
"token_usage": token_usage,
"average_session_interactions": average_session_interactions,
"average_response_time": average_response_time,
"tokens_per_second": tokens_per_second,
"user_satisfaction_rate": user_satisfaction_rate,
}
@staticmethod
def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
total_messages = sum(int(row["message_count"] or 0) for row in rows)
total_conversations = sum(int(row["conversation_count"] or 0) for row in rows)
total_end_users = sum(int(row["end_user_count"] or 0) for row in rows)
total_tokens = sum(int(row["token_count"] or 0) for row in rows)
total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows)
total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows)
total_latency = sum(float(row["latency_sum"] or 0) for row in rows)
weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows)
total_likes = sum(int(row["like_count"] or 0) for row in rows)
return {
"total_messages": total_messages,
"total_conversations": total_conversations,
"total_end_users": total_end_users,
"total_tokens": total_tokens,
"total_price": str(total_price),
"currency": "USD",
"average_session_interactions": round(total_messages / total_conversations, 2)
if total_conversations
else 0,
"average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0,
"tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0,
"user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0,
}
+226 -3
View File
@@ -1,6 +1,6 @@
from typing import Any, TypedDict
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from libs.datetime_utils import naive_utc_now
@@ -19,7 +19,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App, AppMode
from models.model import App, AppMode, IconType
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
@@ -29,7 +29,10 @@ from services.agent.errors import (
AgentNotFoundError,
AgentVersionNotFoundError,
)
from services.app_service import AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
from services.feature_service import FeatureService
class AgentReferencingWorkflow(TypedDict):
@@ -48,6 +51,28 @@ class AgentReferencingWorkflow(TypedDict):
class AgentRosterService:
_APP_MODEL_CONFIG_COPY_FIELDS = (
"opening_statement",
"suggested_questions",
"suggested_questions_after_answer",
"speech_to_text",
"text_to_speech",
"more_like_this",
"model",
"user_input_form",
"dataset_query_variable",
"pre_prompt",
"agent_mode",
"sensitive_word_avoidance",
"retriever_resource",
"prompt_type",
"chat_prompt_config",
"completion_prompt_config",
"dataset_configs",
"external_data_tools",
"file_upload",
)
def __init__(self, session: Any):
self._session = session
@@ -56,6 +81,7 @@ class AgentRosterService:
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
active_config_is_published: bool = False,
) -> dict[str, Any]:
published_references = published_references or []
return {
@@ -74,6 +100,7 @@ class AgentRosterService:
"workflow_node_id": agent.workflow_node_id,
"active_config_snapshot_id": agent.active_config_snapshot_id,
"active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None,
"active_config_is_published": active_config_is_published,
"status": agent.status.value,
"created_by": agent.created_by,
"updated_by": agent.updated_by,
@@ -128,6 +155,10 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
)
data = []
for agent in agents:
@@ -139,6 +170,7 @@ class AgentRosterService:
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
)
@@ -165,11 +197,16 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=agents,
)
data = [
self.serialize_agent(
agent,
versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
for agent in agents
]
@@ -406,6 +443,142 @@ class AgentRosterService:
raise AgentNotFoundError()
return app
def duplicate_agent_app(
self,
*,
tenant_id: str,
agent_id: str,
account: Any,
name: str | None = None,
description: str | None = None,
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
) -> App:
source_app = self.get_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
source_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=source_app.id)
if source_agent is None:
raise AgentNotFoundError()
copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name)
copied_description = description if description is not None else source_app.description
copied_icon_type = icon_type if icon_type is not None else source_app.icon_type
copied_icon = icon if icon is not None else source_app.icon
copied_icon_background = icon_background if icon_background is not None else source_app.icon_background
target_app = AppService().create_app(
tenant_id,
CreateAppParams(
name=copied_name,
description=copied_description,
mode="agent",
agent_role=source_agent.role or "",
icon_type=self._normalize_app_icon_type(copied_icon_type),
icon=copied_icon,
icon_background=copied_icon_background,
api_rph=source_app.api_rph or 0,
api_rpm=source_app.api_rpm or 0,
max_active_requests=source_app.max_active_requests,
),
account,
)
target_app.enable_site = source_app.enable_site
target_app.enable_api = source_app.enable_api
target_app.use_icon_as_answer_icon = source_app.use_icon_as_answer_icon
target_app.tracing = source_app.tracing
self._copy_app_model_config(source_app=source_app, target_app=target_app, account_id=account.id)
self._copy_agent_active_snapshot(
tenant_id=tenant_id,
source_agent=source_agent,
target_app_id=target_app.id,
account_id=account.id,
)
self._session.commit()
if FeatureService.get_system_features().webapp_auth.enabled:
try:
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id)
access_mode = original_settings.access_mode
except Exception:
access_mode = "public"
EnterpriseService.WebAppAuth.update_app_access_mode(target_app.id, access_mode)
return target_app
@staticmethod
def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None:
if icon_type is None:
return None
if isinstance(icon_type, IconType):
return icon_type.value
return icon_type
def _copy_app_model_config(self, *, source_app: App, target_app: App, account_id: str) -> None:
source_config = source_app.app_model_config
target_config = target_app.app_model_config
if source_config is None or target_config is None:
return
for field_name in self._APP_MODEL_CONFIG_COPY_FIELDS:
setattr(target_config, field_name, getattr(source_config, field_name))
target_config.updated_by = account_id
def _copy_agent_active_snapshot(
self,
*,
tenant_id: str,
source_agent: Agent,
target_app_id: str,
account_id: str,
) -> None:
target_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=target_app_id)
if target_agent is None:
raise AgentNotFoundError()
source_version = self._get_version(
tenant_id=tenant_id,
agent_id=source_agent.id,
version_id=source_agent.active_config_snapshot_id,
)
target_version = self._get_version(
tenant_id=tenant_id,
agent_id=target_agent.id,
version_id=target_agent.active_config_snapshot_id,
)
target_version.config_snapshot = AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
target_version.summary = source_version.summary
target_version.version_note = source_version.version_note
target_version.created_by = account_id
target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot)
target_agent.updated_by = account_id
def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str:
suffix = " copy"
max_base_len = 255 - len(suffix)
first_candidate = f"{base_name[:max_base_len]}{suffix}"
candidates = [first_candidate]
for index in range(2, 100):
numbered_suffix = f" copy {index}"
candidates.append(f"{base_name[: 255 - len(numbered_suffix)]}{numbered_suffix}")
existing_names = set(
self._session.scalars(
select(Agent.name).where(
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
Agent.name.in_(candidates),
)
).all()
)
for candidate in candidates:
if candidate not in existing_names:
return candidate
return f"{base_name[:245]} copy {int(naive_utc_now().timestamp())}"
def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]:
"""List the workflow apps that reference this Agent App's bound Agent.
@@ -420,6 +593,12 @@ class AgentRosterService:
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, [])
def load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:
"""Return published workflow references grouped by roster Agent id."""
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids)
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
active_version = self._get_version(
@@ -429,7 +608,16 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_ids=[agent.id],
)
return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, []))
active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=[agent],
)
return self.serialize_agent(
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
active_config_is_published_by_agent_id.get(agent.id, False),
)
def update_roster_agent(
self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload
@@ -471,6 +659,18 @@ class AgentRosterService:
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
"""Return whether the Agent's current active snapshot is a visible published version."""
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
agent.id,
False,
)
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return publish-state flags for the active config snapshots of the given Agents."""
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
return {agent.id: agent.id in published_agent_ids for agent in agents}
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
visible_version_ids = (
@@ -568,6 +768,29 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
return version
def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]:
predicates = [
and_(
AgentConfigRevision.agent_id == agent.id,
AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id,
AgentConfigRevision.operation.in_(self._visible_version_operations(agent)),
)
for agent in agents
if agent.active_config_snapshot_id
]
if not predicates:
return set()
agent_ids = self._session.scalars(
select(AgentConfigRevision.agent_id)
.where(
AgentConfigRevision.tenant_id == tenant_id,
or_(*predicates),
)
.distinct()
).all()
return set(agent_ids)
def _load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:
@@ -70,6 +70,13 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
- Safe default: if tenant mapping or plan is missing, do NOT delete
"""
_graceful_period_days: int
_tenant_whitelist: Sequence[str]
_tenant_whitelist_set: set[str]
_plan_provider: Callable[[Sequence[str]], dict[str, SubscriptionPlan]]
_current_timestamp: int | None
_plan_cache: dict[str, SubscriptionPlan | None]
def __init__(
self,
plan_provider: Callable[[Sequence[str]], dict[str, SubscriptionPlan]],
@@ -79,8 +86,10 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
) -> None:
self._graceful_period_days = graceful_period_days
self._tenant_whitelist: Sequence[str] = tenant_whitelist or []
self._tenant_whitelist_set = set(self._tenant_whitelist)
self._plan_provider = plan_provider
self._current_timestamp = current_timestamp
self._plan_cache = {}
@override
def filter_message_ids(
@@ -101,9 +110,12 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
if not messages or not app_to_tenant:
return []
# Get unique tenant_ids and fetch subscription plans
tenant_ids = list(set(app_to_tenant.values()))
tenant_plans = self._plan_provider(tenant_ids)
# Get unique tenant_ids and fetch subscription plans. Plans are cached for the whole
# policy lifetime because message cleanup evaluates many adjacent batches from the same apps.
tenant_ids = sorted(
{tenant_id for tenant_id in app_to_tenant.values() if tenant_id not in self._tenant_whitelist_set}
)
tenant_plans = self._get_tenant_plans(tenant_ids)
if not tenant_plans:
return []
@@ -115,6 +127,28 @@ class BillingSandboxPolicy(MessagesCleanPolicy):
tenant_plans=tenant_plans,
)
def _get_tenant_plans(self, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
"""
Return cached subscription plans for tenant ids.
Missing billing responses are cached as None and remain a safe non-delete decision.
Provider exceptions still propagate so transient billing failures do not silently change cleanup behavior.
"""
unique_tenant_ids = sorted(set(tenant_ids))
missing_tenant_ids = [tenant_id for tenant_id in unique_tenant_ids if tenant_id not in self._plan_cache]
if missing_tenant_ids:
fetched_plans = self._plan_provider(missing_tenant_ids)
for tenant_id in missing_tenant_ids:
self._plan_cache[tenant_id] = fetched_plans.get(tenant_id)
plans: dict[str, SubscriptionPlan] = {}
for tenant_id in unique_tenant_ids:
plan = self._plan_cache.get(tenant_id)
if plan is not None:
plans[tenant_id] = plan
return plans
def _filter_expired_sandbox_messages(
self,
messages: Sequence[SimpleMessage],
@@ -1,8 +1,9 @@
import datetime
import logging
import math
import random
import time
from collections.abc import Sequence
from collections.abc import Iterator, Sequence
from typing import TYPE_CHECKING, TypedDict, cast
import sqlalchemy as sa
@@ -37,6 +38,10 @@ if TYPE_CHECKING:
from opentelemetry.metrics import Counter, Histogram
_MIN_ELIGIBLE_HIT_RATE = 0.005
_HIT_RATE_EMA_ALPHA = 0.3
class MessagesCleanupMetrics:
"""
Records low-cardinality OpenTelemetry metrics for expired message cleanup jobs.
@@ -165,12 +170,24 @@ class MessagesCleanStatsDict(TypedDict):
total_deleted: int
class MessageDeleteResultDict(TypedDict):
messages_deleted: int
chunks: int
relations_ms: int
messages_ms: int
class MessagesCleanService:
"""
Service for cleaning expired messages based on retention policies.
Compatible with non cloud edition (billing disabled): all messages in the time range will be deleted.
If billing is enabled: only sandbox plan tenant messages are deleted (with whitelist and grace period support).
The scan cursor advances by candidate messages, not messages selected by the policy. This keeps cleanup moving
through windows dominated by paid, unknown, or grace-period tenants without asking SQL to find enough eligible rows.
Candidate scan batches can grow independently from delete batches, while deletes stay chunked into
small transactions.
"""
def __init__(
@@ -179,6 +196,8 @@ class MessagesCleanService:
end_before: datetime.datetime,
start_from: datetime.datetime | None = None,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> None:
@@ -189,14 +208,28 @@ class MessagesCleanService:
policy: The policy that determines which messages to delete
end_before: End time (exclusive) of the range
start_from: Optional start time (inclusive) of the range
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
"""
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
self._policy = policy
self._end_before = end_before
self._start_from = start_from
self._batch_size = batch_size
self._candidate_batch_size = batch_size
self._delete_batch_size = delete_batch_size or batch_size
self._max_candidate_batch_size = max(max_candidate_batch_size or batch_size, self._candidate_batch_size)
self._dry_run = dry_run
self._metrics = MessagesCleanupMetrics(
dry_run=dry_run,
@@ -211,6 +244,8 @@ class MessagesCleanService:
start_from: datetime.datetime,
end_before: datetime.datetime,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> "MessagesCleanService":
@@ -223,7 +258,9 @@ class MessagesCleanService:
policy: The policy that determines which messages to delete
start_from: Start time (inclusive) of the range
end_before: End time (exclusive) of the range
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
@@ -239,11 +276,20 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
logger.info(
"clean_messages: start_from=%s, end_before=%s, batch_size=%s, policy=%s",
"clean_messages: start_from=%s, end_before=%s, batch_size=%s, "
"max_candidate_batch_size=%s, delete_batch_size=%s, policy=%s",
start_from,
end_before,
batch_size,
max_candidate_batch_size,
delete_batch_size,
policy.__class__.__name__,
)
@@ -252,6 +298,8 @@ class MessagesCleanService:
end_before=end_before,
start_from=start_from,
batch_size=batch_size,
max_candidate_batch_size=max_candidate_batch_size,
delete_batch_size=delete_batch_size,
dry_run=dry_run,
task_label=task_label,
)
@@ -262,6 +310,8 @@ class MessagesCleanService:
policy: MessagesCleanPolicy,
days: int = 30,
batch_size: int = 1000,
max_candidate_batch_size: int | None = None,
delete_batch_size: int | None = None,
dry_run: bool = False,
task_label: str = "custom",
) -> "MessagesCleanService":
@@ -271,7 +321,9 @@ class MessagesCleanService:
Args:
policy: The policy that determines which messages to delete
days: Number of days to look back from now
batch_size: Number of messages to process per batch
batch_size: Initial number of candidate messages to scan per batch
max_candidate_batch_size: Maximum number of candidate messages to scan per batch
delete_batch_size: Maximum number of messages to delete per transaction
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Optional task label for retention metrics
@@ -287,13 +339,22 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
if max_candidate_batch_size is not None and max_candidate_batch_size <= 0:
raise ValueError(f"max_candidate_batch_size ({max_candidate_batch_size}) must be greater than 0")
if delete_batch_size is not None and delete_batch_size <= 0:
raise ValueError(f"delete_batch_size ({delete_batch_size}) must be greater than 0")
end_before = naive_utc_now() - datetime.timedelta(days=days)
logger.info(
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
"clean_messages: days=%s, end_before=%s, batch_size=%s, "
"max_candidate_batch_size=%s, delete_batch_size=%s, policy=%s",
days,
end_before,
batch_size,
max_candidate_batch_size,
delete_batch_size,
policy.__class__.__name__,
)
@@ -302,6 +363,8 @@ class MessagesCleanService:
end_before=end_before,
start_from=None,
batch_size=batch_size,
max_candidate_batch_size=max_candidate_batch_size,
delete_batch_size=delete_batch_size,
dry_run=dry_run,
task_label=task_label,
)
@@ -333,10 +396,10 @@ class MessagesCleanService:
Time range is [start_from, end_before)
Steps:
1. Iterate messages using cursor pagination (by created_at, id)
2. Query app_id -> tenant_id mapping
1. Iterate candidate messages using cursor pagination (by created_at, id)
2. Resolve app_id -> tenant_id mapping with a job-level cache
3. Delegate to policy to determine which messages to delete
4. Batch delete messages and their relations
4. Delete messages and their relations in small chunks
Returns:
Dict with statistics: batches, filtered_messages, total_deleted
@@ -348,19 +411,23 @@ class MessagesCleanService:
"total_deleted": 0,
}
# Cursor-based pagination using (created_at, id) to avoid infinite loops
# and ensure proper ordering with time-based filtering
_cursor: tuple[datetime.datetime, str] | None = None
cursor: tuple[datetime.datetime, str] | None = None
app_to_tenant_cache: dict[str, str | None] = {}
current_candidate_batch_size = self._candidate_batch_size
smoothed_hit_rate: float | None = None
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
logger.info(
"clean_messages: start cleaning messages (dry_run=%s), start_from=%s, end_before=%s",
"clean_messages: start cleaning messages (dry_run=%s), start_from=%s, end_before=%s, "
"candidate_batch_size=%s, max_candidate_batch_size=%s, delete_batch_size=%s",
self._dry_run,
self._start_from,
self._end_before,
self._candidate_batch_size,
self._max_candidate_batch_size,
self._delete_batch_size,
)
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
while True:
stats["batches"] += 1
batch_start = time.monotonic()
@@ -369,25 +436,25 @@ class MessagesCleanService:
batch_deleted_messages = 0
# Step 1: Fetch a batch of messages using cursor
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
with session_factory.begin() as session:
fetch_messages_start = time.monotonic()
msg_stmt = (
select(Message.id, Message.app_id, Message.created_at)
.where(Message.created_at < self._end_before)
.order_by(Message.created_at, Message.id)
.limit(self._batch_size)
.limit(current_candidate_batch_size)
)
if self._start_from:
msg_stmt = msg_stmt.where(Message.created_at >= self._start_from)
# Apply cursor condition: (created_at, id) > (last_created_at, last_message_id)
if _cursor:
if cursor:
msg_stmt = msg_stmt.where(
tuple_(Message.created_at, Message.id)
> tuple_(
sa.literal(_cursor[0], type_=sa.DateTime()),
sa.literal(_cursor[1], type_=Message.id.type),
sa.literal(cursor[0], type_=sa.DateTime()),
sa.literal(cursor[1], type_=Message.id.type),
)
)
@@ -397,13 +464,13 @@ class MessagesCleanService:
for msg_id, app_id, msg_created_at in raw_messages
]
logger.info(
"clean_messages (batch %s): fetched %s messages in %sms",
"clean_messages (batch %s): fetched %s candidate messages with limit %s in %sms",
stats["batches"],
len(messages),
current_candidate_batch_size,
int((time.monotonic() - fetch_messages_start) * 1000),
)
# Track total messages fetched across all batches
stats["total_messages"] += len(messages)
batch_scanned_messages = len(messages)
@@ -417,29 +484,50 @@ class MessagesCleanService:
)
break
# Update cursor to the last message's (created_at, id)
_cursor = (messages[-1].created_at, messages[-1].id)
# Advance by candidate rows before policy filtering. This avoids retrying the same paid/unknown slice.
cursor = (messages[-1].created_at, messages[-1].id)
# Step 2: Extract app_ids and query tenant_ids
app_ids = list({msg.app_id for msg in messages})
if not app_ids:
logger.info("clean_messages (batch %s): no app_ids found, skip", stats["batches"])
smoothed_hit_rate, current_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=0,
)
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
fetch_apps_start = time.monotonic()
app_stmt = select(App.id, App.tenant_id).where(App.id.in_(app_ids))
apps = list(session.execute(app_stmt).all())
app_to_tenant, app_cache_misses, apps_found = self._load_app_to_tenant_mapping(
session=session,
app_ids=app_ids,
app_to_tenant_cache=app_to_tenant_cache,
)
logger.info(
"clean_messages (batch %s): fetched %s apps for %s app_ids in %sms",
"clean_messages (batch %s): resolved %s apps for %s app_ids (cache_misses=%s, found=%s) in %sms",
stats["batches"],
len(apps),
len(app_to_tenant),
len(app_ids),
app_cache_misses,
apps_found,
int((time.monotonic() - fetch_apps_start) * 1000),
)
if not apps:
if not app_to_tenant:
logger.info("clean_messages (batch %s): no apps found, skip", stats["batches"])
smoothed_hit_rate, current_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=0,
)
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
@@ -448,19 +536,27 @@ class MessagesCleanService:
)
continue
# Build app_id -> tenant_id mapping
app_to_tenant: dict[str, str] = {app.id: app.tenant_id for app in apps}
# Step 3: Delegate to policy to determine which messages to delete
policy_start = time.monotonic()
message_ids_to_delete = self._policy.filter_message_ids(messages, app_to_tenant)
message_ids_to_delete = list(self._policy.filter_message_ids(messages, app_to_tenant))
batch_filtered_messages = len(message_ids_to_delete)
stats["filtered_messages"] += batch_filtered_messages
smoothed_hit_rate, next_candidate_batch_size = self._adjust_candidate_batch_size(
smoothed_hit_rate=smoothed_hit_rate,
candidate_count=batch_scanned_messages,
eligible_count=batch_filtered_messages,
)
logger.info(
"clean_messages (batch %s): policy selected %s/%s messages in %sms",
"clean_messages (batch %s): policy selected %s/%s messages in %sms "
"(smoothed_hit_rate=%.4f, next_candidate_batch_size=%s)",
stats["batches"],
len(message_ids_to_delete),
batch_filtered_messages,
len(messages),
int((time.monotonic() - policy_start) * 1000),
smoothed_hit_rate,
next_candidate_batch_size,
)
current_candidate_batch_size = next_candidate_batch_size
if not message_ids_to_delete:
logger.info("clean_messages (batch %s): no messages to delete, skip", stats["batches"])
@@ -472,47 +568,32 @@ class MessagesCleanService:
)
continue
stats["filtered_messages"] += len(message_ids_to_delete)
batch_filtered_messages = len(message_ids_to_delete)
# Step 4: Batch delete messages and their relations
if not self._dry_run:
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
delete_relations_start = time.monotonic()
# Delete related records first
self._batch_delete_message_relations(session, message_ids_to_delete)
delete_relations_ms = int((time.monotonic() - delete_relations_start) * 1000)
delete_result = self._delete_messages_in_chunks(
session_factory=session_factory,
message_ids=message_ids_to_delete,
)
# Delete messages
delete_messages_start = time.monotonic()
delete_stmt = delete(Message).where(Message.id.in_(message_ids_to_delete))
delete_result = cast(CursorResult, session.execute(delete_stmt))
messages_deleted = delete_result.rowcount
delete_messages_ms = int((time.monotonic() - delete_messages_start) * 1000)
commit_ms = 0
stats["total_deleted"] += delete_result["messages_deleted"]
batch_deleted_messages = delete_result["messages_deleted"]
stats["total_deleted"] += messages_deleted
batch_deleted_messages = messages_deleted
logger.info(
"clean_messages (batch %s): processed %s candidate messages, deleted %s messages in %s chunks",
stats["batches"],
len(messages),
delete_result["messages_deleted"],
delete_result["chunks"],
)
logger.info(
"clean_messages (batch %s): relations %sms, messages %sms, batch total %sms",
stats["batches"],
delete_result["relations_ms"],
delete_result["messages_ms"],
int((time.monotonic() - batch_start) * 1000),
)
logger.info(
"clean_messages (batch %s): processed %s messages, deleted %s messages",
stats["batches"],
len(messages),
messages_deleted,
)
logger.info(
"clean_messages (batch %s): relations %sms, messages %sms, commit %sms, batch total %sms",
stats["batches"],
delete_relations_ms,
delete_messages_ms,
commit_ms,
int((time.monotonic() - batch_start) * 1000),
)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("clean_messages (batch %s): sleeping for %.2fms", stats["batches"], sleep_ms)
time.sleep(sleep_ms / 1000)
self._sleep_after_batch(stats["batches"], batch_deleted_messages)
else:
# Log random sample of message IDs that would be deleted (up to 10)
sample_size = min(10, len(message_ids_to_delete))
@@ -544,6 +625,105 @@ class MessagesCleanService:
return stats
@staticmethod
def _load_app_to_tenant_mapping(
*,
session: Session,
app_ids: Sequence[str],
app_to_tenant_cache: dict[str, str | None],
) -> tuple[dict[str, str], int, int]:
unique_app_ids = sorted(set(app_ids))
missing_app_ids = [app_id for app_id in unique_app_ids if app_id not in app_to_tenant_cache]
found_apps = 0
if missing_app_ids:
app_stmt = select(App.id, App.tenant_id).where(App.id.in_(missing_app_ids))
apps = list(session.execute(app_stmt).all())
found_app_ids: set[str] = set()
for app_id, tenant_id in apps:
app_to_tenant_cache[app_id] = tenant_id
found_app_ids.add(app_id)
found_apps = len(found_app_ids)
for app_id in set(missing_app_ids) - found_app_ids:
app_to_tenant_cache[app_id] = None
app_to_tenant: dict[str, str] = {}
for app_id in unique_app_ids:
tenant_id = app_to_tenant_cache.get(app_id)
if tenant_id is not None:
app_to_tenant[app_id] = tenant_id
return app_to_tenant, len(missing_app_ids), found_apps
def _adjust_candidate_batch_size(
self,
*,
smoothed_hit_rate: float | None,
candidate_count: int,
eligible_count: int,
) -> tuple[float, int]:
if candidate_count <= 0:
next_smoothed_hit_rate = smoothed_hit_rate or 0.0
return next_smoothed_hit_rate, self._candidate_batch_size_for_hit_rate(next_smoothed_hit_rate)
hit_rate = eligible_count / candidate_count
if smoothed_hit_rate is None:
next_smoothed_hit_rate = hit_rate
else:
next_smoothed_hit_rate = (_HIT_RATE_EMA_ALPHA * hit_rate) + ((1 - _HIT_RATE_EMA_ALPHA) * smoothed_hit_rate)
return next_smoothed_hit_rate, self._candidate_batch_size_for_hit_rate(next_smoothed_hit_rate)
def _candidate_batch_size_for_hit_rate(self, hit_rate: float) -> int:
effective_hit_rate = max(hit_rate, _MIN_ELIGIBLE_HIT_RATE)
desired_batch_size = math.ceil(self._delete_batch_size / effective_hit_rate)
lower_bound = min(self._delete_batch_size, self._max_candidate_batch_size)
return min(max(desired_batch_size, lower_bound), self._max_candidate_batch_size)
def _delete_messages_in_chunks(
self,
*,
session_factory: sessionmaker[Session],
message_ids: Sequence[str],
) -> MessageDeleteResultDict:
result: MessageDeleteResultDict = {
"messages_deleted": 0,
"chunks": 0,
"relations_ms": 0,
"messages_ms": 0,
}
for message_id_chunk in self._iter_message_id_chunks(message_ids, self._delete_batch_size):
result["chunks"] += 1
with session_factory.begin() as session:
delete_relations_start = time.monotonic()
self._batch_delete_message_relations(session, message_id_chunk)
result["relations_ms"] += int((time.monotonic() - delete_relations_start) * 1000)
delete_messages_start = time.monotonic()
delete_stmt = delete(Message).where(Message.id.in_(message_id_chunk))
delete_result = cast(CursorResult, session.execute(delete_stmt))
result["messages_deleted"] += delete_result.rowcount
result["messages_ms"] += int((time.monotonic() - delete_messages_start) * 1000)
return result
@staticmethod
def _iter_message_id_chunks(message_ids: Sequence[str], chunk_size: int) -> Iterator[Sequence[str]]:
for start_index in range(0, len(message_ids), chunk_size):
yield message_ids[start_index : start_index + chunk_size]
def _sleep_after_batch(self, batch_index: int, deleted_messages: int) -> None:
if deleted_messages <= 0:
return
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
sleep_ratio = min(1.0, deleted_messages / self._delete_batch_size)
sleep_ms = random.uniform(0, max_batch_interval_ms * sleep_ratio) # noqa: S311
logger.info("clean_messages (batch %s): sleeping for %.2fms", batch_index, sleep_ms)
time.sleep(sleep_ms / 1000)
@staticmethod
def _batch_delete_message_relations(session: Session, message_ids: Sequence[str]) -> None:
"""
@@ -1,3 +1,10 @@
"""Cleanup expired workflow run logs for free-plan tenants.
The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and
deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same
candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models.
"""
import datetime
import logging
import random
@@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from models.workflow import WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
RunsWithRelatedCountsDict,
WorkflowRunCleanupRef,
)
from repositories.factory import DifyAPIRepositoryFactory
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.billing_service import BillingService, SubscriptionPlan
@@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs
class WorkflowRunCleanup:
"""
Coordinates free-plan workflow run retention cleanup.
The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable
when billing filters out paid or unknown tenants before the repository performs the target lookup.
"""
def __init__(
self,
days: int,
@@ -254,26 +271,28 @@ class WorkflowRunCleanup:
batch_start = time.monotonic()
fetch_start = time.monotonic()
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
candidate_last_seen = last_seen
candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=last_seen,
last_seen=candidate_last_seen,
batch_size=self.batch_size,
)
if not run_rows:
if not candidate_refs:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
batch_index += 1
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
candidate_high_water = self._cursor_from_ref(candidate_refs[-1])
last_seen = candidate_high_water
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
"workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms",
batch_index,
len(run_rows),
len(candidate_refs),
int((time.monotonic() - fetch_start) * 1000),
)
tenant_ids = {row.tenant_id for row in run_rows}
tenant_ids = {ref.tenant_id for ref in candidate_refs}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
@@ -285,10 +304,28 @@ class WorkflowRunCleanup:
int((time.monotonic() - filter_start) * 1000),
)
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
paid_or_skipped = len(run_rows) - len(free_runs)
target_refs: Sequence[WorkflowRunCleanupRef] = []
if free_tenants:
target_fetch_start = time.monotonic()
target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=candidate_last_seen,
batch_size=self.batch_size,
tenant_ids=sorted(free_tenants),
upper_bound=candidate_high_water,
)
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s target refs in %sms",
batch_index,
len(target_refs),
int((time.monotonic() - target_fetch_start) * 1000),
)
if not free_runs:
target_run_ids = [ref.id for ref in target_refs]
paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0)
if not target_run_ids:
skipped_message = (
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
)
@@ -299,7 +336,7 @@ class WorkflowRunCleanup:
)
)
self._metrics.record_batch(
batch_rows=len(run_rows),
batch_rows=len(candidate_refs),
targeted_runs=0,
skipped_runs=paid_or_skipped,
deleted_runs=0,
@@ -309,13 +346,13 @@ class WorkflowRunCleanup:
)
continue
total_runs_targeted += len(free_runs)
total_runs_targeted += len(target_run_ids)
if self.dry_run:
count_start = time.monotonic()
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids(
target_run_ids,
count_node_executions=self._count_node_executions_by_run_ids,
count_trigger_logs=self._count_trigger_logs,
)
logger.info(
@@ -325,10 +362,10 @@ class WorkflowRunCleanup:
)
if related_totals is not None:
self._accumulate_related_counts(related_totals, batch_counts)
sample_ids = ", ".join(run.id for run in free_runs[:5])
sample_ids = ", ".join(target_run_ids[:5])
click.echo(
click.style(
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
f"[batch #{batch_index}] would delete {len(target_run_ids)} runs "
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
fg="yellow",
)
@@ -339,8 +376,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
skipped_runs=paid_or_skipped,
deleted_runs=0,
related_counts={
@@ -354,14 +391,14 @@ class WorkflowRunCleanup:
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
counts = self.workflow_run_repo.delete_runs_with_related_by_ids(
target_run_ids,
delete_node_executions=self._delete_node_executions_by_run_ids,
delete_trigger_logs=self._delete_trigger_logs,
)
delete_ms = int((time.monotonic() - delete_start) * 1000)
except Exception:
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0])
raise
total_runs_deleted += counts["runs"]
@@ -382,8 +419,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
skipped_runs=paid_or_skipped,
deleted_runs=counts["runs"],
related_counts={
@@ -439,7 +476,7 @@ class WorkflowRunCleanup:
)
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
tenant_id_list = list(tenant_ids)
tenant_id_list = sorted(set(tenant_ids))
if not dify_config.BILLING_ENABLED:
return set(tenant_id_list)
@@ -553,15 +590,17 @@ class WorkflowRunCleanup:
totals["pauses"] += batch.get("pauses", 0)
totals["pause_reasons"] += batch.get("pause_reasons", 0)
def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
@staticmethod
def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]:
return ref.created_at, ref.id
def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
)
return repo.count_by_runs(session, run_ids)
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
)
@@ -1,11 +1,14 @@
import datetime
import math
import uuid
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from enums.cloud_plan import CloudPlan
from models import Tenant
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import (
@@ -15,7 +18,7 @@ from models.model import (
MessageAnnotation,
MessageFeedback,
)
from services.retention.conversation.messages_clean_policy import BillingDisabledPolicy
from services.retention.conversation.messages_clean_policy import BillingDisabledPolicy, BillingSandboxPolicy
from services.retention.conversation.messages_clean_service import MessagesCleanService
_NOW = datetime.datetime(2026, 1, 15, 12, 0, 0, tzinfo=datetime.UTC)
@@ -91,6 +94,35 @@ def _make_message(app_id: str, conversation_id: str, created_at: datetime.dateti
)
def _create_tenant_app_conversation(session: Session, name_suffix: str) -> tuple[str, str, str]:
tenant = Tenant(name=f"retention_it_tenant_{name_suffix}")
session.add(tenant)
session.flush()
app = App(
tenant_id=tenant.id,
name=f"Retention IT App {name_suffix}",
mode="chat",
enable_site=True,
enable_api=True,
)
session.add(app)
session.flush()
conv = Conversation(
app_id=app.id,
mode="chat",
name=f"test_conv_{name_suffix}",
status="normal",
from_source="console",
_inputs={},
)
session.add(conv)
session.flush()
return tenant.id, app.id, conv.id
class TestMessagesCleanServiceIntegration:
@pytest.fixture
def seed_messages(self, tenant_and_app):
@@ -285,6 +317,117 @@ class TestMessagesCleanServiceIntegration:
remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(msg_ids)))
assert remaining == 0
def test_candidate_cursor_advances_when_first_batch_has_no_eligible_messages(self, flask_req_ctx):
"""A paid-only candidate batch must not prevent later sandbox messages from being cleaned."""
del flask_req_ctx
with session_factory.create_session() as session:
paid_tenant_id, paid_app_id, paid_conv_id = _create_tenant_app_conversation(session, "paid")
free_tenant_id, free_app_id, free_conv_id = _create_tenant_app_conversation(session, "free")
paid_msg_1 = _make_message(paid_app_id, paid_conv_id, _OLD)
paid_msg_2 = _make_message(paid_app_id, paid_conv_id, _OLD + datetime.timedelta(seconds=1))
free_msg = _make_message(free_app_id, free_conv_id, _OLD + datetime.timedelta(seconds=2))
session.add_all([paid_msg_1, paid_msg_2, free_msg])
session.commit()
paid_message_ids = [paid_msg_1.id, paid_msg_2.id]
free_message_id = free_msg.id
app_ids = [paid_app_id, free_app_id]
conversation_ids = [paid_conv_id, free_conv_id]
tenant_ids = [paid_tenant_id, free_tenant_id]
plan_map = {
paid_tenant_id: {"plan": CloudPlan.PROFESSIONAL, "expiration_date": -1},
free_tenant_id: {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
}
plan_provider = MagicMock(
side_effect=lambda tenant_ids: {tenant_id: plan_map[tenant_id] for tenant_id in tenant_ids}
)
policy = BillingSandboxPolicy(plan_provider=plan_provider, graceful_period_days=21)
try:
with patch("services.retention.conversation.messages_clean_service.time.sleep"):
svc = MessagesCleanService.from_time_range(
policy=policy,
start_from=_OLD - datetime.timedelta(seconds=1),
end_before=_OLD + datetime.timedelta(seconds=3),
batch_size=2,
max_candidate_batch_size=2,
delete_batch_size=1,
)
stats = svc.run()
assert stats["total_messages"] == 3
assert stats["filtered_messages"] == 1
assert stats["total_deleted"] == 1
assert plan_provider.call_count == 2
assert set(plan_provider.call_args_list[0].args[0]) == {paid_tenant_id}
assert set(plan_provider.call_args_list[1].args[0]) == {free_tenant_id}
with session_factory.create_session() as session:
remaining_paid = session.scalar(
select(func.count()).select_from(Message).where(Message.id.in_(paid_message_ids))
)
remaining_free = session.scalar(
select(func.count()).select_from(Message).where(Message.id == free_message_id)
)
assert remaining_paid == 2
assert remaining_free == 0
finally:
with session_factory.create_session() as session:
session.execute(delete(Message).where(Message.id.in_([*paid_message_ids, free_message_id])))
session.execute(delete(Conversation).where(Conversation.id.in_(conversation_ids)))
session.execute(delete(App).where(App.id.in_(app_ids)))
session.execute(delete(Tenant).where(Tenant.id.in_(tenant_ids)))
session.commit()
def test_delete_batch_size_chunks_eligible_message_deletes(self, tenant_and_app):
"""Candidate scans can be larger than the delete transaction chunk size."""
data = tenant_and_app
app_id = data["app_id"]
conv_id = data["conversation_id"]
msg_ids: list[str] = []
with session_factory.create_session() as session:
for index in range(5):
msg = _make_message(app_id, conv_id, _OLD + datetime.timedelta(seconds=index))
session.add(msg)
session.flush()
msg_ids.append(msg.id)
session.commit()
try:
with (
patch.object(
MessagesCleanService,
"_batch_delete_message_relations",
wraps=MessagesCleanService._batch_delete_message_relations,
) as delete_relations,
patch("services.retention.conversation.messages_clean_service.time.sleep"),
):
svc = MessagesCleanService.from_time_range(
policy=BillingDisabledPolicy(),
start_from=_OLD - datetime.timedelta(seconds=1),
end_before=_OLD + datetime.timedelta(seconds=6),
batch_size=5,
max_candidate_batch_size=5,
delete_batch_size=2,
)
stats = svc.run()
assert stats["total_deleted"] == 5
assert delete_relations.call_count == 3
with session_factory.create_session() as session:
remaining = session.scalar(select(func.count()).select_from(Message).where(Message.id.in_(msg_ids)))
assert remaining == 0
finally:
with session_factory.create_session() as session:
session.execute(delete(Message).where(Message.id.in_(msg_ids)))
session.commit()
def test_no_messages_in_range_returns_empty_stats(self, seed_messages):
"""A window entirely in the future must yield zero matches."""
far_future = _NOW + datetime.timedelta(days=365)
@@ -0,0 +1,320 @@
"""Integration tests for workflow run cleanup repository queries."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import override
from uuid import uuid4
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from graphon.entities import WorkflowExecution
from graphon.entities.pause_reason import PauseReasonType
from graphon.enums import WorkflowExecutionStatus, WorkflowType
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository):
"""Concrete repository for tests where save() is not under test."""
@override
def save(self, execution: WorkflowExecution) -> None:
return None
@dataclass
class _TestScope:
"""Per-test identifiers for rows created by cleanup repository tests."""
tenant_id: str = field(default_factory=lambda: str(uuid4()))
app_id: str = field(default_factory=lambda: str(uuid4()))
workflow_id: str = field(default_factory=lambda: str(uuid4()))
user_id: str = field(default_factory=lambda: str(uuid4()))
def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository:
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False))
def _create_workflow_run(
session: Session,
scope: _TestScope,
*,
status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED,
created_at: datetime,
tenant_id: str | None = None,
workflow_id: str | None = None,
workflow_type: str = WorkflowType.WORKFLOW,
) -> WorkflowRun:
workflow_run = WorkflowRun(
id=str(uuid4()),
tenant_id=tenant_id or scope.tenant_id,
app_id=scope.app_id,
workflow_id=workflow_id or scope.workflow_id,
type=workflow_type,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
version="draft",
graph="{}",
inputs="{}",
status=status,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=scope.user_id,
created_at=created_at,
)
session.add(workflow_run)
session.commit()
return workflow_run
def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None:
session.add(
WorkflowAppLog(
tenant_id=workflow_run.tenant_id,
app_id=scope.app_id,
workflow_id=workflow_run.workflow_id,
workflow_run_id=workflow_run.id,
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=scope.user_id,
)
)
session.commit()
def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause:
pause = WorkflowPause(
workflow_id=workflow_run.workflow_id,
workflow_run_id=workflow_run.id,
state_object_key=f"workflow-state-{uuid4()}.json",
)
pause_reason = WorkflowPauseReason(
pause_id=pause.id,
type_=PauseReasonType.SCHEDULED_PAUSE,
message="scheduled pause",
)
session.add_all([pause, pause_reason])
session.commit()
return pause
class TestGetCleanupRefsBatchByTimeRange:
def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
base = datetime(2024, 1, 1, 12, 0, 0)
_create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1))
cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base)
first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1))
second_target = _create_workflow_run(
db_session_with_containers,
scope,
status=WorkflowExecutionStatus.FAILED,
created_at=base + timedelta(minutes=2),
)
_create_workflow_run(
db_session_with_containers,
scope,
status=WorkflowExecutionStatus.RUNNING,
created_at=base + timedelta(minutes=1),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
tenant_id=str(uuid4()),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
workflow_id=str(uuid4()),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
workflow_type=WorkflowType.CHAT,
)
_create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3))
refs = repository.get_cleanup_refs_batch_by_time_range(
start_from=base,
end_before=base + timedelta(minutes=4),
last_seen=(cursor_run.created_at, cursor_run.id),
batch_size=10,
run_types=[WorkflowType.WORKFLOW],
tenant_ids=[scope.tenant_id],
workflow_ids=[scope.workflow_id],
upper_bound=(second_target.created_at, second_target.id),
)
assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [
(first_target.id, scope.tenant_id, first_target.created_at),
(second_target.id, scope.tenant_id, second_target.created_at),
]
def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
refs = repository.get_cleanup_refs_batch_by_time_range(
start_from=None,
end_before=datetime(2024, 1, 2),
last_seen=None,
batch_size=10,
run_types=[],
)
assert refs == []
class TestCountRunsWithRelatedByIds:
def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
missing_run_id = str(uuid4())
_add_app_log(db_session_with_containers, scope, workflow_run)
_add_pause_with_reason(db_session_with_containers, workflow_run)
counted_node_run_ids: list[str] = []
counted_trigger_run_ids: list[str] = []
counts = repository.count_runs_with_related_by_ids(
[workflow_run.id, missing_run_id],
count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1),
count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3,
)
assert counted_node_run_ids == [workflow_run.id, missing_run_id]
assert counted_trigger_run_ids == [workflow_run.id, missing_run_id]
assert counts == {
"runs": 1,
"node_executions": 2,
"offloads": 1,
"app_logs": 1,
"trigger_logs": 3,
"pauses": 1,
"pause_reasons": 1,
}
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
counts = repository.count_runs_with_related_by_ids([workflow_run.id])
assert counts == {
"runs": 1,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
class TestDeleteRunsWithRelatedByIds:
def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
_add_app_log(db_session_with_containers, scope, workflow_run)
pause = _add_pause_with_reason(db_session_with_containers, workflow_run)
pause_id = pause.id
deleted_node_run_ids: list[str] = []
deleted_trigger_run_ids: list[str] = []
counts = repository.delete_runs_with_related_by_ids(
[workflow_run.id],
delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1),
delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3,
)
assert deleted_node_run_ids == [workflow_run.id]
assert deleted_trigger_run_ids == [workflow_run.id]
assert counts == {
"runs": 1,
"node_executions": 2,
"offloads": 1,
"app_logs": 1,
"trigger_logs": 3,
"pauses": 1,
"pause_reasons": 1,
}
verification_session = Session(bind=db_session_with_containers.get_bind())
with verification_session:
assert verification_session.get(WorkflowRun, workflow_run.id) is None
assert verification_session.get(WorkflowPause, pause_id) is None
assert (
verification_session.scalar(
select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id)
)
is None
)
assert (
verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id))
is None
)
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
counts = repository.delete_runs_with_related_by_ids([workflow_run.id])
assert counts == {
"runs": 1,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
assert repository.count_runs_with_related_by_ids([]) == {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
assert repository.delete_runs_with_related_by_ids([]) == {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
@@ -1,7 +1,6 @@
from __future__ import annotations
import logging
from unittest.mock import patch
from uuid import uuid4
import pytest
@@ -259,9 +258,8 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert len(matching_logs) == 1
@patch("services.end_user_service.logger")
def test_get_existing_end_user_matching_type(
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog
):
"""Test retrieving existing end user with matching type."""
# Arrange
@@ -279,17 +277,19 @@ class TestEndUserServiceGetOrCreateEndUserByType:
)
# Act - Request with same type
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.SERVICE_API,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
with caplog.at_level(logging.INFO, logger="services.end_user_service"):
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.SERVICE_API,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
# Assert
assert result.id == existing_user.id
assert result.type == InvokeFrom.SERVICE_API
mock_logger.info.assert_not_called()
# No legacy-upgrade log should be emitted when the existing user's type already matches.
assert [record for record in caplog.records if record.levelno == logging.INFO] == []
def test_create_anonymous_user_with_default_session(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory
@@ -1,5 +1,6 @@
import logging
import uuid
from unittest.mock import ANY, call, patch
from unittest.mock import call, patch
import pytest
from sqlalchemy import delete, func, select
@@ -146,10 +147,7 @@ class TestDeleteDraftVariablesBatch:
assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0
@patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data")
@patch("tasks.remove_app_and_related_data_task.logger")
def test_delete_draft_variables_batch_logs_progress(
self, mock_logger, mock_offload_cleanup, db_session_with_containers
):
def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog):
"""Test that batch deletion logs progress correctly."""
tenant, app = _create_tenant_and_app(db_session_with_containers)
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=10)
@@ -163,14 +161,15 @@ class TestDeleteDraftVariablesBatch:
mock_offload_cleanup.return_value = len(file_id_by_index)
result = delete_draft_variables_batch(app.id, 50)
with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"):
result = delete_draft_variables_batch(app.id, 50)
assert result == 30
mock_offload_cleanup.assert_called_once()
_, called_file_ids = mock_offload_cleanup.call_args.args
assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()}
assert mock_logger.info.call_count == 2
mock_logger.info.assert_any_call(ANY)
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
assert len(info_records) == 2
class TestDeleteDraftVariableOffloadData:
@@ -204,10 +203,7 @@ class TestDeleteDraftVariableOffloadData:
assert remaining_upload_files_count == 0
@patch("extensions.ext_storage.storage")
@patch("tasks.remove_app_and_related_data_task.logging")
def test_delete_draft_variable_offload_data_storage_failure(
self, mock_logging, mock_storage, db_session_with_containers
):
def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog):
"""Test handling of storage deletion failures."""
tenant, app = _create_tenant_and_app(db_session_with_containers)
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=2)
@@ -217,11 +213,12 @@ class TestDeleteDraftVariableOffloadData:
mock_storage.delete.side_effect = [Exception("Storage error"), None]
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
with caplog.at_level(logging.ERROR):
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
assert result == 1
mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0])
assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text
remaining_var_files_count = db_session_with_containers.scalar(
select(func.count())
@@ -21,10 +21,13 @@ from controllers.console.agent.composer import (
)
from controllers.console.agent.roster import (
AgentAppApi,
AgentAppCopyApi,
AgentAppListApi,
AgentInviteOptionsApi,
AgentLogsApi,
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
AgentStatisticsSummaryApi,
)
from controllers.console.app import completion as completion_controller
from controllers.console.app import message as message_controller
@@ -138,6 +141,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/composer/validate",
"/agent/<uuid:agent_id>/composer/candidates",
"/agent/<uuid:agent_id>/features",
"/agent/<uuid:agent_id>/copy",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/sandbox/files",
@@ -148,6 +152,8 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/feedbacks",
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
"/agent/<uuid:agent_id>/logs",
"/agent/<uuid:agent_id>/statistics/summary",
"/agent/invite-options",
):
assert route in paths
@@ -199,12 +205,36 @@ def test_agent_app_list_and_create_use_agent_route(
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_app_backing_agents_by_app_id",
lambda _self, **kwargs: {"app-list": SimpleNamespace(id="agent-list", role="List role")},
lambda _self, **kwargs: {
"app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None)
},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(id="agent-created", role="Created role"),
lambda _self, **kwargs: SimpleNamespace(
id="agent-created", role="Created role", active_config_snapshot_id=None
),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_published_references_by_agent_id",
lambda _self, **kwargs: {
"agent-list": [
{
"app_id": "workflow-app-id",
"app_name": "RFP Review Flow",
"app_icon_type": "emoji",
"app_icon": "A",
"app_icon_background": "#fff",
"app_mode": "workflow",
"app_updated_at": 1781660000,
"workflow_id": "workflow-1",
"workflow_version": "v1",
"node_ids": ["node-1", "node-2"],
}
]
},
)
monkeypatch.setattr(
roster_controller.FeatureService,
@@ -221,6 +251,17 @@ def test_agent_app_list_and_create_use_agent_route(
assert listed["data"][0]["id"] == "agent-list"
assert listed["data"][0]["app_id"] == "app-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["active_config_is_published"] is False
assert listed["data"][0]["published_reference_count"] == 1
assert listed["data"][0]["published_references"] == [
{
"app_id": "workflow-app-id",
"app_name": "RFP Review Flow",
"app_icon_type": "emoji",
"app_icon": "A",
"app_icon_background": "#fff",
}
]
assert "bound_agent_id" not in listed["data"][0]
list_call = cast(dict[str, object], captured["list"])
list_params = cast(Any, list_call["params"])
@@ -237,6 +278,7 @@ def test_agent_app_list_and_create_use_agent_route(
assert created["id"] == "agent-created"
assert created["app_id"] == "app-created"
assert created["role"] == "Created role"
assert created["active_config_is_published"] is False
assert "bound_agent_id" not in created
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
@@ -258,7 +300,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_app_backing_agent",
lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role"),
lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None),
)
monkeypatch.setattr(
roster_controller.FeatureService,
@@ -284,6 +326,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert detail["id"] == agent_id
assert detail["app_id"] == "app-1"
assert detail["role"] == "Resolved role"
assert detail["active_config_is_published"] is False
assert "bound_agent_id" not in detail
with app.test_request_context(
@@ -296,6 +339,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert updated["id"] == agent_id
assert updated["app_id"] == "app-1"
assert updated["role"] == "Resolved role"
assert updated["active_config_is_published"] is False
assert "bound_agent_id" not in updated
update_call = cast(dict[str, object], captured["update"])
assert update_call["app"] is app_model
@@ -305,6 +349,52 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert captured["delete"] is app_model
def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
current_user = SimpleNamespace(id=account_id)
copied_app = _app_detail_obj(id="copied-app", bound_agent_id="copied-agent")
captured: dict[str, object] = {}
class FakeRosterService:
def duplicate_agent_app(self, **kwargs: object) -> object:
captured.update(kwargs)
return copied_app
monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda: FakeRosterService())
monkeypatch.setattr(
roster_controller,
"_serialize_agent_app_detail",
lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name},
)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/copy",
json={
"name": "Iris copy",
"description": "Copied",
"icon_type": "emoji",
"icon": "sparkles",
"icon_background": "#fff",
},
):
copied, status = unwrap(AgentAppCopyApi.post)(AgentAppCopyApi(), "tenant-1", current_user, agent_id)
assert status == 201
assert copied == {"id": "copied-agent", "app_id": "copied-app", "name": "Iris"}
assert captured == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account": current_user,
"name": "Iris copy",
"description": "Copied",
"icon_type": "emoji",
"icon": "sparkles",
"icon_background": "#fff",
}
def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
@@ -363,6 +453,108 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
assert version_detail["agent_id"] == agent_id
def test_agent_observability_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1")
captured: dict[str, object] = {}
class FakeObservabilityService:
def list_logs(self, *, app, params):
captured["logs"] = {"app": app, "params": params}
return {
"data": [
{
"id": "message-1",
"message_id": "message-1",
"conversation_id": "conversation-1",
"conversation_name": "Debug",
"query": "hello",
"answer": "hi",
"status": "success",
"error": None,
"source": "explore",
"from_source": "console",
"from_end_user_id": None,
"from_account_id": account_id,
"message_tokens": 1,
"answer_tokens": 2,
"total_tokens": 3,
"total_price": "0",
"currency": "USD",
"latency": 1.2,
"created_at": 1,
"updated_at": 2,
}
],
"page": 2,
"limit": 5,
"total": 6,
"has_more": False,
}
def get_statistics_summary(self, *, app, params):
captured["statistics"] = {"app": app, "params": params}
return {
"source": "all",
"summary": {
"total_messages": 1,
"total_conversations": 1,
"total_end_users": 1,
"total_tokens": 3,
"total_price": "0",
"currency": "USD",
"average_session_interactions": 1,
"average_response_time": 1200,
"tokens_per_second": 2,
"user_satisfaction_rate": 100,
},
"charts": {
"daily_messages": [{"date": "2026-06-17", "message_count": 1}],
"daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}],
"daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}],
"token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}],
"average_session_interactions": [{"date": "2026-06-17", "interactions": 1}],
"average_response_time": [{"date": "2026-06-17", "latency": 1200}],
"tokens_per_second": [{"date": "2026-06-17", "tps": 2}],
"user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}],
},
}
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService())
account = SimpleNamespace(id=account_id, timezone="UTC")
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs"
"?page=2&limit=5&keyword=hello&status=success&source=console"
):
logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id)
assert logs["data"][0]["id"] == "message-1"
logs_call = cast(dict[str, object], captured["logs"])
assert logs_call["app"] is app_model
logs_params = cast(Any, logs_call["params"])
assert logs_params.page == 2
assert logs_params.limit == 5
assert logs_params.keyword == "hello"
assert logs_params.status == "success"
assert logs_params.source == "console"
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api"
):
statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id)
assert statistics["summary"]["total_messages"] == 1
stats_call = cast(dict[str, object], captured["statistics"])
assert stats_call["app"] is app_model
stats_params = cast(Any, stats_call["params"])
assert stats_params.source == "api"
assert stats_params.timezone == "UTC"
def test_workflow_composer_get_put_validate_candidates_impact_and_save(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
@@ -64,6 +64,7 @@ class TestInstalledAppsListApi:
assert "app_model_configs" in compiled_filter
assert "workflow_id" in compiled_filter
assert "app_model_config_id" in compiled_filter
assert "apps.mode != 'agent'" in compiled_filter
def test_get_installed_apps(
self, app: Flask, current_user: MagicMock, tenant_id: str, installed_app: MagicMock
@@ -1,3 +1,4 @@
import logging
from unittest.mock import MagicMock, patch
import controllers.console.version as version_module
@@ -18,15 +19,15 @@ class TestHasNewVersion:
)
assert result is False
def test_has_new_version_invalid_version(self):
with patch.object(version_module.logger, "warning") as log_warning:
def test_has_new_version_invalid_version(self, caplog):
with caplog.at_level(logging.WARNING, logger="controllers.console.version"):
result = version_module._has_new_version(
latest_version="invalid",
current_version="1.0.0",
)
assert result is False
log_warning.assert_called_once()
assert "Invalid version format" in caplog.text
class TestCheckVersionUpdate:
@@ -2,6 +2,7 @@
Unit tests for Service API File Preview endpoint
"""
import logging
import uuid
from unittest.mock import Mock, patch
@@ -348,8 +349,7 @@ class TestFilePreviewApi:
assert "Storage error" in str(exc_info.value)
@patch("controllers.service_api.app.file_preview.logger")
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog):
"""Test that unexpected errors are logged properly"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
@@ -359,14 +359,18 @@ class TestFilePreviewApi:
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
# Verify error message
assert "File access validation failed" in str(exc_info.value)
# Verify logging was called
mock_logger.exception.assert_called_once_with(
"Unexpected error during file ownership validation",
extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"},
)
# Verify logging was called with the structured context fields. The ``extra`` keys
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.getMessage() == "Unexpected error during file ownership validation"
assert record.file_id == file_id
assert record.app_id == app_id
assert record.error == "Unexpected database error"
@@ -160,6 +160,16 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
)
def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent:
return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent))
def _saved_user_query(qm: _FakeQueueManager) -> str:
prompt_messages = _message_end(qm).llm_result.prompt_messages
assert len(prompt_messages) == 1
return prompt_messages[0].content
def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"}
store = _FakeSessionStore()
@@ -175,6 +185,7 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
assert chunk_events[0].chunk.delta.message.content == "hello agent"
assert end_events[0].llm_result.message.content == "hello agent"
assert end_events[0].llm_result.model == "gpt-4o-mini"
assert _saved_user_query(qm) == "hello"
# The conversation session snapshot is persisted for multi-turn continuity.
assert store.saved
saved_scope, saved_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0]
@@ -256,6 +267,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation():
assert created_params.conversation_id == "conv-1"
assert created_params.workflow_execution_id is None
assert [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert _saved_user_query(qm) == "hello"
# The pause correlation is persisted so a form submission can resume the run.
assert store.saved
assert store.saved[0][4] == "form-1"
@@ -65,6 +65,13 @@ def _answer_text(events: list[Any]) -> str:
return end.llm_result.message.content
def _saved_user_query(events: list[Any]) -> str:
end = next(e for e in events if isinstance(e, QueueMessageEndEvent))
prompt_messages = end.llm_result.prompt_messages
assert len(prompt_messages) == 1
return prompt_messages[0].content
class TestRunInputGuards:
def test_no_guards_passes_through(self, monkeypatch):
_patch_moderation(monkeypatch, returns=(False, {}, "hello"))
@@ -113,6 +120,7 @@ class TestRunInputGuards:
assert handled is True
assert any(isinstance(e, QueueLLMChunkEvent) for e in qm.events)
assert _answer_text(qm.events) == "blocked preset answer"
assert _saved_user_query(qm.events) == "forbidden"
def test_annotation_hit_short_circuits(self, monkeypatch):
_patch_moderation(monkeypatch, returns=(False, {}, "what is your name"))
@@ -131,3 +139,4 @@ class TestRunInputGuards:
assert len(annotation_events) == 1
assert annotation_events[0].message_annotation_id == "anno-1"
assert _answer_text(qm.events) == "I am the annotated Iris."
assert _saved_user_query(qm.events) == "what is your name"
@@ -1,3 +1,4 @@
import logging
from unittest.mock import Mock, patch
from core.app.layers.timeslice_layer import TimeSliceLayer
@@ -64,21 +65,19 @@ class TestTimeSliceLayer:
scheduler.remove_job.assert_called_once_with("job-1")
def test_checker_job_handles_resource_limit_without_command_channel(self):
def test_checker_job_handles_resource_limit_without_command_channel(self, caplog):
scheduler = Mock()
scheduler.running = True
cfs_plan_scheduler = Mock(plan=Mock())
cfs_plan_scheduler.can_schedule.return_value = SchedulerCommand.RESOURCE_LIMIT_REACHED
with (
patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler),
patch("core.app.layers.timeslice_layer.logger") as mock_logger,
):
with patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler):
layer = TimeSliceLayer(cfs_plan_scheduler=cfs_plan_scheduler)
layer._checker_job("job-1")
with caplog.at_level(logging.ERROR, logger="core.app.layers.timeslice_layer"):
layer._checker_job("job-1")
scheduler.remove_job.assert_called_once_with("job-1")
mock_logger.exception.assert_called_once()
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_checker_job_sends_pause_command(self):
scheduler = Mock()
@@ -1,3 +1,4 @@
import logging
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -114,7 +115,7 @@ class TestTriggerPostLayer:
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
def test_on_event_handles_missing_trigger_log(self):
def test_on_event_handles_missing_trigger_log(self, caplog):
runtime_state = SimpleNamespace(
outputs={},
variable_pool=VariablePool.from_bootstrap(
@@ -126,7 +127,6 @@ class TestTriggerPostLayer:
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.logger") as mock_logger,
):
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
@@ -142,9 +142,10 @@ class TestTriggerPostLayer:
)
layer.initialize(runtime_state, Mock())
layer.on_event(GraphRunFailedEvent(error="boom"))
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
layer.on_event(GraphRunFailedEvent(error="boom"))
mock_logger.exception.assert_called_once()
assert any(record.levelno == logging.ERROR for record in caplog.records)
session.commit.assert_not_called()
def test_on_event_ignores_non_status_events(self):
@@ -1,5 +1,6 @@
"""Unit tests for the message cycle manager optimization."""
import logging
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -344,7 +345,7 @@ class TestMessageCycleManagerOptimization:
db_session.close.assert_called_once()
mock_redis.setex.assert_called_once()
def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager):
def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager, caplog):
"""Fallback to truncated query when LLM generation fails."""
flask_app = Flask(__name__)
conversation = SimpleNamespace(
@@ -362,19 +363,19 @@ class TestMessageCycleManagerOptimization:
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config,
patch("core.app.task_pipeline.message_cycle_manager.logger") as mock_logger,
):
mock_db.session = db_session
mock_redis.get.return_value = None
mock_llm_generator.generate_conversation_name.side_effect = RuntimeError("generation failed")
mock_dify_config.DEBUG = True
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query)
with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"):
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query)
assert conversation.name == (long_query[:47] + "...")
db_session.commit.assert_called_once()
db_session.close.assert_called_once()
mock_logger.exception.assert_called_once()
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager):
"""Populate task metadata from annotation reply events.
@@ -1,3 +1,4 @@
import logging
import queue
import time
from concurrent.futures import Future, ThreadPoolExecutor
@@ -511,10 +512,8 @@ def test_receive_loop_http_error_unknown_id(streams):
@pytest.mark.timeout(10)
def test_receive_loop_validation_error_notification(streams):
from core.mcp.session.base_session import logger
with patch.object(logger, "warning") as mock_warning:
def test_receive_loop_validation_error_notification(streams, caplog):
with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"):
read_stream, write_stream = streams
session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification])
@@ -523,7 +522,7 @@ def test_receive_loop_validation_error_notification(streams):
read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload)))
time.sleep(1.0)
assert mock_warning.called
assert "Failed to validate notification" in caplog.text
@pytest.mark.timeout(5)
@@ -571,16 +570,16 @@ def test_session_exit_timeout(streams):
@pytest.mark.timeout(10)
def test_receive_loop_fatal_exception(streams):
def test_receive_loop_fatal_exception(streams, caplog):
read_stream, write_stream = streams
session = MockSession(read_stream, write_stream, ReceiveRequest, ReceiveNotification)
with patch.object(read_stream, "get", side_effect=RuntimeError("Fatal loop error")):
with patch("core.mcp.session.base_session.logger") as mock_logger:
with caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"):
with pytest.raises(RuntimeError, match="Fatal loop error"):
with session:
pass
mock_logger.exception.assert_called_with("Error in message processing loop")
assert "Error in message processing loop" in caplog.text
@pytest.mark.timeout(5)
@@ -7,6 +7,7 @@ This test file covers the methods not fully tested in test_embedding_service.py:
"""
import base64
import logging
from decimal import Decimal
from unittest.mock import Mock, patch
@@ -188,7 +189,7 @@ class TestCacheEmbeddingMultimodalDocuments:
assert len(result) == 3
assert result[0] == normalized_cached
def test_embed_multimodal_documents_nan_handling(self, mock_model_instance):
def test_embed_multimodal_documents_nan_handling(self, mock_model_instance, caplog):
"""Test handling of NaN values in multimodal embeddings."""
cache_embedding = CacheEmbedding(mock_model_instance)
documents = [{"file_id": "valid"}, {"file_id": "nan"}]
@@ -216,14 +217,14 @@ class TestCacheEmbeddingMultimodalDocuments:
mock_session.scalar.return_value = None
mock_model_instance.invoke_multimodal_embedding.return_value = embedding_result
with patch("core.rag.embedding.cached_embedding.logger") as mock_logger:
with caplog.at_level(logging.WARNING, logger="core.rag.embedding.cached_embedding"):
result = cache_embedding.embed_multimodal_documents(documents)
assert len(result) == 2
assert result[0] is not None
assert result[1] is None
mock_logger.warning.assert_called_once()
assert any(record.levelno == logging.WARNING for record in caplog.records)
def test_embed_multimodal_documents_large_batch(self, mock_model_instance):
"""Test embedding large batch of multimodal documents respecting MAX_CHUNKS."""
@@ -463,7 +464,7 @@ class TestCacheEmbeddingQueryErrors:
model_instance.credentials = {"api_key": "test-key"}
return model_instance
def test_embed_query_api_error_debug_mode(self, mock_model_instance):
def test_embed_query_api_error_debug_mode(self, mock_model_instance, caplog):
"""Test handling of API errors in debug mode."""
cache_embedding = CacheEmbedding(mock_model_instance)
query = "test query"
@@ -475,14 +476,14 @@ class TestCacheEmbeddingQueryErrors:
with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config:
mock_config.DEBUG = True
with patch("core.rag.embedding.cached_embedding.logger") as mock_logger:
with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"):
with pytest.raises(RuntimeError) as exc_info:
cache_embedding.embed_query(query)
assert "API Error" in str(exc_info.value)
mock_logger.exception.assert_called()
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance):
def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance, caplog):
"""Test handling of Redis set errors in debug mode."""
cache_embedding = CacheEmbedding(mock_model_instance)
query = "test query"
@@ -514,11 +515,11 @@ class TestCacheEmbeddingQueryErrors:
with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config:
mock_config.DEBUG = True
with patch("core.rag.embedding.cached_embedding.logger") as mock_logger:
with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"):
with pytest.raises(RuntimeError):
cache_embedding.embed_query(query)
mock_logger.exception.assert_called()
assert any(record.levelno == logging.ERROR for record in caplog.records)
class TestCacheEmbeddingInitialization:
@@ -1,6 +1,7 @@
"""Primarily used for testing merged cell scenarios"""
import io
import logging
import os
import tempfile
from collections import UserDict
@@ -548,7 +549,7 @@ def test_parse_docx_reads_real_paragraph_table_order(monkeypatch: pytest.MonkeyP
os.remove(tmp_path)
def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch):
def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch, caplog):
extractor = object.__new__(WordExtractor)
ext_image_id = "ext-image"
@@ -709,10 +710,9 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke
monkeypatch.setattr(we, "Run", FakeRun)
monkeypatch.setattr(extractor, "_extract_images_from_docx", lambda doc: image_map)
monkeypatch.setattr(extractor, "_table_to_markdown", lambda table, image_map: "TABLE-MARKDOWN")
logger_exception = MagicMock()
monkeypatch.setattr(we.logger, "exception", logger_exception)
content = extractor.parse_docx("dummy.docx")
with caplog.at_level(logging.ERROR, logger="core.rag.extractor.word_extractor"):
content = extractor.parse_docx("dummy.docx")
assert "[EXT]" in content
assert "[INT]" in content
@@ -720,7 +720,7 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke
assert "[LinkText](https://example.com)" in content
assert "BrokenLink" in content
assert "TABLE-MARKDOWN" in content
logger_exception.assert_called_once()
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_parse_cell_paragraph_hyperlink_in_table_cell_http():
@@ -126,6 +126,7 @@ Run with coverage:
"""
import asyncio
import logging
import string
import sys
import types
@@ -644,13 +645,13 @@ class TestTextSplitterBasePaths:
with pytest.raises(NotImplementedError):
asyncio.run(splitter.atransform_documents([Document(page_content="x", metadata={})]))
def test_merge_splits_logs_warning_for_oversized_total(self):
def test_merge_splits_logs_warning_for_oversized_total(self, caplog):
"""Cover logger.warning path in _merge_splits."""
splitter = RecursiveCharacterTextSplitter(chunk_size=5, chunk_overlap=1)
with patch("core.rag.splitter.text_splitter.logger.warning") as mock_warning:
with caplog.at_level(logging.WARNING, logger="core.rag.splitter.text_splitter"):
merged = splitter._merge_splits(["abcdefghij", "b"], "", [10, 1])
assert merged
mock_warning.assert_called_once()
assert any(record.levelno == logging.WARNING for record in caplog.records)
# ============================================================================
@@ -17,6 +17,7 @@ from core.tools.entities.tool_entities import (
ToolInvokeMessage,
ToolParameter,
)
from core.tools.tool_manager import ToolManager
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
WorkflowAgentPluginToolsBuilder,
WorkflowAgentPluginToolsBuildError,
@@ -32,6 +33,8 @@ class FakeRuntimeProvider:
self.tool = tool
self.last_agent_tool: AgentToolEntity | None = None
self.last_invoke_from: InvokeFrom | None = None
self.last_allow_file_parameters: bool | None = None
self.last_use_default_for_missing_form_parameters: bool | None = None
def get_agent_tool_runtime(
self,
@@ -41,11 +44,25 @@ class FakeRuntimeProvider:
user_id: str | None = None,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Any | None = None,
allow_file_parameters: bool = False,
use_default_for_missing_form_parameters: bool = False,
) -> Tool:
self.last_agent_tool = agent_tool
self.last_invoke_from = invoke_from
self.last_allow_file_parameters = allow_file_parameters
self.last_use_default_for_missing_form_parameters = use_default_for_missing_form_parameters
if isinstance(self.tool, Exception):
raise self.tool
if self.tool.runtime is not None:
runtime_parameters = ToolManager._convert_tool_parameters_type(
self.tool.get_merged_runtime_parameters(),
variable_pool,
agent_tool.tool_parameters,
typ="agent",
allow_file_parameters=allow_file_parameters,
use_default_for_missing_form_parameters=use_default_for_missing_form_parameters,
)
self.tool.runtime.runtime_parameters.update(runtime_parameters)
return self.tool
@@ -103,6 +120,67 @@ def _tool(*, runtime_parameters: dict[str, Any] | None = None) -> FakeTool:
return FakeTool(entity=entity, runtime=runtime)
def _file_tool() -> FakeTool:
parameters = [
ToolParameter(
name="audio_file",
label=I18nObject(en_US="Audio File"),
type=ToolParameter.ToolParameterType.FILE,
form=ToolParameter.ToolParameterForm.LLM,
required=True,
llm_description="The audio file to be converted.",
)
]
entity = ToolEntity(
identity=ToolIdentity(
author="langgenius",
name="asr",
label=I18nObject(en_US="Speech To Text"),
provider="audio",
),
description=ToolDescription(human=I18nObject(en_US="Speech To Text"), llm="Convert audio file to text."),
parameters=parameters,
)
runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={})
return FakeTool(entity=entity, runtime=runtime)
def _tts_tool() -> FakeTool:
parameters = [
ToolParameter(
name="text",
label=I18nObject(en_US="Text"),
type=ToolParameter.ToolParameterType.STRING,
form=ToolParameter.ToolParameterForm.LLM,
required=True,
llm_description="The text to be converted.",
),
ToolParameter(
name="model",
label=I18nObject(en_US="Model"),
type=ToolParameter.ToolParameterType.SELECT,
form=ToolParameter.ToolParameterForm.FORM,
required=True,
options=[
{"value": "provider-a#model-a", "label": {"en_US": "model-a(provider-a)"}},
{"value": "provider-b#model-b", "label": {"en_US": "model-b(provider-b)"}},
],
),
]
entity = ToolEntity(
identity=ToolIdentity(
author="langgenius",
name="tts",
label=I18nObject(en_US="Text To Speech"),
provider="audio",
),
description=ToolDescription(human=I18nObject(en_US="Text To Speech"), llm="Convert text to audio file."),
parameters=parameters,
)
runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={})
return FakeTool(entity=entity, runtime=runtime)
def _build(
builder: WorkflowAgentPluginToolsBuilder,
tools: AgentSoulToolsConfig,
@@ -157,6 +235,62 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime():
assert runtime_provider.last_agent_tool.provider_type.value == "plugin"
def test_builds_dify_plugin_tool_with_file_llm_parameter():
runtime_provider = FakeRuntimeProvider(_file_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "audio",
"provider_type": "builtin",
"tool_name": "asr",
"credential_type": "unauthorized",
}
]
}
)
result = _build(builder, tools)
assert result is not None
prepared = result.tools[0]
assert prepared.tool_name == "asr"
assert prepared.runtime_parameters == {}
assert prepared.parameters[0].name == "audio_file"
assert prepared.parameters[0].type == "file"
# The public Agent backend DTO carries non-scalar tool inputs in
# ``parameters``; legacy JSON schema generation omits file fields.
assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []}
assert runtime_provider.last_allow_file_parameters is True
assert runtime_provider.last_use_default_for_missing_form_parameters is True
def test_builds_dify_plugin_tool_with_missing_required_select_default():
runtime_provider = FakeRuntimeProvider(_tts_tool())
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_id": "audio",
"provider_type": "builtin",
"tool_name": "tts",
"credential_type": "unauthorized",
}
]
}
)
result = _build(builder, tools)
assert result is not None
prepared = result.tools[0]
assert prepared.tool_name == "tts"
assert prepared.runtime_parameters == {"model": "provider-a#model-a"}
assert runtime_provider.last_use_default_for_missing_form_parameters is True
def test_rejects_duplicate_exposed_tool_names():
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool()))
tools = AgentSoulToolsConfig.model_validate(
@@ -21,6 +21,9 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
AgentSoulModelConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
@@ -321,6 +324,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
"secret_refs": [
{"variable": "TOKEN", "credential_id": "credential-1"},
{"name": "API_KEY", "provider_credential_id": "credential-2"},
{"name": "EDITABLE_TOKEN", "value": "credential-3"},
{"ref": "missing-name"},
],
},
@@ -341,6 +345,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
assert config["secret_refs"] == [
{"name": "TOKEN", "ref": "credential-1"},
{"name": "API_KEY", "ref": "credential-2"},
{"name": "EDITABLE_TOKEN", "ref": "credential-3"},
]
assert config["sandbox"] is None
@@ -630,6 +635,40 @@ def test_array_output_emits_typed_items_per_array_item():
assert output_schema["required"] == ["tags"]
def test_nested_declared_output_emits_object_and_array_child_schema():
profile_output = DeclaredOutputConfig(
name="profile",
type=DeclaredOutputType.OBJECT,
children=[
DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING),
DeclaredOutputChildConfig(
name="nickname",
type=DeclaredOutputType.STRING,
required=False,
description="Optional display name",
),
DeclaredOutputChildConfig(
name="addresses",
type=DeclaredOutputType.ARRAY,
array_item=DeclaredArrayItem(
type=DeclaredOutputType.OBJECT,
description="Address item",
children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)],
),
),
],
)
schema = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(profile_output)
assert schema["properties"]["email"] == {"type": "string"}
assert schema["properties"]["nickname"] == {"type": "string", "description": "Optional display name"}
assert schema["properties"]["addresses"]["items"]["properties"]["city"] == {"type": "string"}
assert schema["properties"]["addresses"]["items"]["description"] == "Address item"
assert schema["properties"]["addresses"]["items"]["required"] == ["city"]
assert schema["required"] == ["email", "addresses"]
def test_effective_declared_outputs_passthrough_when_user_declared():
"""effective_declared_outputs() must return user-provided outputs verbatim
when non-empty; only empty input gets PRD defaults injected."""
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_
assert ("x-custom", "foo") in headers
@patch("enterprise.telemetry.exporter.logger")
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
@patch("enterprise.telemetry.exporter.GRPCMetricExporter")
def test_api_key_overrides_conflicting_header(
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog
) -> None:
"""Test that API key overrides conflicting authorization header and logs warning."""
mock_config = SimpleNamespace(
@@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header(
ENTERPRISE_OTLP_API_KEY="test-key",
)
EnterpriseExporter(mock_config)
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
EnterpriseExporter(mock_config)
# Verify Bearer header takes precedence
assert mock_span_exporter.call_args is not None
@@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header(
assert ("authorization", "Basic old") not in headers
# Verify warning was logged
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args is not None
warning_message = mock_logger.warning.call_args[0][0]
assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message
assert "authorization" in warning_message
assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text
assert "authorization" in caplog.text
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
@@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None:
assert kwargs["context"] is not None
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None:
def test_export_span_logs_exception_on_error(caplog) -> None:
"""If the span block raises, the exception is logged and context is still cleared."""
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
mock_tracer.start_as_current_span.side_effect = RuntimeError("boom")
exporter.export_span(name="bad.span", attributes={}) # must not raise
with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"):
exporter.export_span(name="bad.span", attributes={}) # must not raise
mock_logger.exception.assert_called_once()
assert "bad.span" in mock_logger.exception.call_args[0][1]
assert "Failed to export span" in caplog.text
assert "bad.span" in caplog.text
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None:
def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None:
"""Invalid UUID for trace_correlation_override triggers a warning log."""
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3"
exporter.export_span(
name="link.span",
attributes={},
correlation_id="not-a-valid-uuid",
parent_span_id_source=parent_uid,
)
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
exporter.export_span(
name="link.span",
attributes={},
correlation_id="not-a-valid-uuid",
parent_span_id_source=parent_uid,
)
mock_logger.warning.assert_called()
assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text
# ---------------------------------------------------------------------------
@@ -2,8 +2,10 @@
from __future__ import annotations
import logging
import uuid
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# compute_trace_id_hex
@@ -135,134 +137,128 @@ class TestEmitTelemetryLog:
compute_trace_id_hex.cache_clear()
compute_span_id_hex.cache_clear()
@patch("enterprise.telemetry.telemetry_log.logger")
def test_logs_info_with_event_name_and_signal(self, mock_logger: MagicMock) -> None:
def test_logs_info_with_event_name_and_signal(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(
event_name="dify.workflow.run",
attributes={"tenant_id": "t1"},
signal="metric_only",
)
emit_telemetry_log(
event_name="dify.workflow.run",
attributes={"tenant_id": "t1"},
signal="metric_only",
)
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelno == logging.INFO
assert record.getMessage() == "telemetry.metric_only"
assert hasattr(record, "attributes")
assert record.attributes["dify.event.name"] == "dify.workflow.run"
assert record.attributes["dify.event.signal"] == "metric_only"
assert record.attributes["tenant_id"] == "t1"
mock_logger.info.assert_called_once()
args, kwargs = mock_logger.info.call_args
assert args[0] == "telemetry.%s"
assert args[1] == "metric_only"
extra = kwargs["extra"]
assert extra["attributes"]["dify.event.name"] == "dify.workflow.run"
assert extra["attributes"]["dify.event.signal"] == "metric_only"
@patch("enterprise.telemetry.telemetry_log.logger")
def test_no_log_when_info_disabled(self, mock_logger: MagicMock) -> None:
def test_no_log_when_info_disabled(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = False
with caplog.at_level(logging.WARNING, logger="dify.telemetry"):
emit_telemetry_log(event_name="dify.workflow.run", attributes={})
emit_telemetry_log(event_name="dify.workflow.run", attributes={})
assert len(caplog.records) == 0
mock_logger.info.assert_not_called()
@patch("enterprise.telemetry.telemetry_log.logger")
def test_trace_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None:
def test_trace_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
uid = "123e4567-e89b-12d3-a456-426614174000"
emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid)
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid)
extra = mock_logger.info.call_args.kwargs["extra"]
assert "trace_id" in extra
assert len(extra["trace_id"]) == 32
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "trace_id")
assert len(record.trace_id) == 32
@patch("enterprise.telemetry.telemetry_log.logger")
def test_trace_id_absent_when_invalid_source(self, mock_logger: MagicMock) -> None:
def test_trace_id_absent_when_invalid_source(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id")
emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id")
assert len(caplog.records) == 1
record = caplog.records[0]
assert not hasattr(record, "trace_id")
extra = mock_logger.info.call_args.kwargs["extra"]
assert "trace_id" not in extra
@patch("enterprise.telemetry.telemetry_log.logger")
def test_span_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None:
def test_span_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
uid = "123e4567-e89b-12d3-a456-426614174000"
emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid)
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid)
extra = mock_logger.info.call_args.kwargs["extra"]
assert "span_id" in extra
assert len(extra["span_id"]) == 16
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "span_id")
assert len(record.span_id) == 16
@patch("enterprise.telemetry.telemetry_log.logger")
def test_tenant_id_added_when_provided(self, mock_logger: MagicMock) -> None:
def test_tenant_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99")
emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99")
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "tenant_id")
assert record.tenant_id == "tenant-99"
extra = mock_logger.info.call_args.kwargs["extra"]
assert extra["tenant_id"] == "tenant-99"
@patch("enterprise.telemetry.telemetry_log.logger")
def test_user_id_added_when_provided(self, mock_logger: MagicMock) -> None:
def test_user_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42")
emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42")
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "user_id")
assert record.user_id == "user-42"
extra = mock_logger.info.call_args.kwargs["extra"]
assert extra["user_id"] == "user-42"
@patch("enterprise.telemetry.telemetry_log.logger")
def test_tenant_and_user_id_absent_when_not_provided(self, mock_logger: MagicMock) -> None:
def test_tenant_and_user_id_absent_when_not_provided(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={})
emit_telemetry_log(event_name="test.event", attributes={})
assert len(caplog.records) == 1
record = caplog.records[0]
assert not hasattr(record, "tenant_id")
assert not hasattr(record, "user_id")
extra = mock_logger.info.call_args.kwargs["extra"]
assert "tenant_id" not in extra
assert "user_id" not in extra
@patch("enterprise.telemetry.telemetry_log.logger")
def test_caller_attributes_merged_into_attrs(self, mock_logger: MagicMock) -> None:
def test_caller_attributes_merged_into_attrs(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(
event_name="dify.node.run",
attributes={"node_type": "code", "elapsed": 0.5},
)
emit_telemetry_log(
event_name="dify.node.run",
attributes={"node_type": "code", "elapsed": 0.5},
)
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "attributes")
assert record.attributes["node_type"] == "code"
assert record.attributes["elapsed"] == 0.5
extra = mock_logger.info.call_args.kwargs["extra"]
assert extra["attributes"]["node_type"] == "code"
assert extra["attributes"]["elapsed"] == 0.5
@patch("enterprise.telemetry.telemetry_log.logger")
def test_signal_span_detail_forwarded(self, mock_logger: MagicMock) -> None:
def test_signal_span_detail_forwarded(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_telemetry_log
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail")
emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail")
args = mock_logger.info.call_args[0]
assert args[1] == "span_detail"
extra = mock_logger.info.call_args.kwargs["extra"]
assert extra["attributes"]["dify.event.signal"] == "span_detail"
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.getMessage() == "telemetry.span_detail"
assert hasattr(record, "attributes")
assert record.attributes["dify.event.signal"] == "span_detail"
# ---------------------------------------------------------------------------
@@ -277,51 +273,50 @@ class TestEmitMetricOnlyEvent:
compute_trace_id_hex.cache_clear()
compute_span_id_hex.cache_clear()
@patch("enterprise.telemetry.telemetry_log.logger")
def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, mock_logger: MagicMock) -> None:
def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_metric_only_event
mock_logger.isEnabledFor.return_value = True
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_metric_only_event(
event_name="dify.app.created",
attributes={"app_id": "app-1"},
tenant_id="t1",
user_id="u1",
)
emit_metric_only_event(
event_name="dify.app.created",
attributes={"app_id": "app-1"},
tenant_id="t1",
user_id="u1",
)
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "attributes")
assert record.attributes["dify.event.signal"] == "metric_only"
assert record.attributes["dify.event.name"] == "dify.app.created"
assert record.attributes["app_id"] == "app-1"
assert hasattr(record, "tenant_id")
assert record.tenant_id == "t1"
assert hasattr(record, "user_id")
assert record.user_id == "u1"
mock_logger.info.assert_called_once()
extra = mock_logger.info.call_args.kwargs["extra"]
assert extra["attributes"]["dify.event.signal"] == "metric_only"
assert extra["attributes"]["dify.event.name"] == "dify.app.created"
assert extra["attributes"]["app_id"] == "app-1"
assert extra["tenant_id"] == "t1"
assert extra["user_id"] == "u1"
@patch("enterprise.telemetry.telemetry_log.logger")
def test_trace_and_span_ids_passed_through(self, mock_logger: MagicMock) -> None:
def test_trace_and_span_ids_passed_through(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_metric_only_event
mock_logger.isEnabledFor.return_value = True
uid = "123e4567-e89b-12d3-a456-426614174000"
emit_metric_only_event(
event_name="dify.workflow.run",
attributes={},
trace_id_source=uid,
span_id_source=uid,
)
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
emit_metric_only_event(
event_name="dify.workflow.run",
attributes={},
trace_id_source=uid,
span_id_source=uid,
)
extra = mock_logger.info.call_args.kwargs["extra"]
assert "trace_id" in extra
assert "span_id" in extra
assert len(caplog.records) == 1
record = caplog.records[0]
assert hasattr(record, "trace_id")
assert hasattr(record, "span_id")
@patch("enterprise.telemetry.telemetry_log.logger")
def test_no_log_emitted_when_logger_disabled(self, mock_logger: MagicMock) -> None:
def test_no_log_emitted_when_logger_disabled(self, caplog: pytest.LogCaptureFixture) -> None:
from enterprise.telemetry.telemetry_log import emit_metric_only_event
mock_logger.isEnabledFor.return_value = False
with caplog.at_level(logging.WARNING, logger="dify.telemetry"):
emit_metric_only_event(event_name="dify.workflow.run", attributes={})
emit_metric_only_event(event_name="dify.workflow.run", attributes={})
mock_logger.info.assert_not_called()
assert len(caplog.records) == 0
@@ -1,7 +1,12 @@
import pytest
from core.workflow.file_reference import build_file_reference
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType
from models.agent_config_entities import (
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
)
def test_file_default_value_accepts_canonical_reference_mapping() -> None:
@@ -92,3 +97,90 @@ def test_array_file_default_value_rejects_legacy_item_shape() -> None:
},
}
)
def test_declared_array_item_rejects_nested_arrays_and_non_object_children() -> None:
with pytest.raises(ValueError, match="nested arrays"):
DeclaredArrayItem(type=DeclaredOutputType.ARRAY)
with pytest.raises(ValueError, match="array_item.children"):
DeclaredArrayItem(
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)],
)
def test_declared_output_child_validates_shape_and_defaults() -> None:
file_child = DeclaredOutputChildConfig(name="report", type=DeclaredOutputType.FILE)
assert file_child.file is not None
array_child = DeclaredOutputChildConfig(name="items", type=DeclaredOutputType.ARRAY)
assert array_child.array_item is not None
assert array_child.array_item.type == DeclaredOutputType.OBJECT
with pytest.raises(ValueError, match="output child name"):
DeclaredOutputChildConfig(name="bad-name", type=DeclaredOutputType.STRING)
with pytest.raises(ValueError, match="file metadata"):
DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING, file={})
with pytest.raises(ValueError, match="array_item is only allowed"):
DeclaredOutputChildConfig(
name="title",
type=DeclaredOutputType.STRING,
array_item={"type": DeclaredOutputType.STRING},
)
with pytest.raises(ValueError, match="children is only allowed"):
DeclaredOutputChildConfig(
name="title",
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)],
)
def test_declared_output_validates_shape_and_defaults() -> None:
file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
assert file_output.file is not None
array_output = DeclaredOutputConfig(name="items", type=DeclaredOutputType.ARRAY)
assert array_output.array_item is not None
assert array_output.array_item.type == DeclaredOutputType.OBJECT
default_failure_strategy = DeclaredOutputConfig.model_validate(
{"name": "summary", "type": "string", "failure_strategy": None}
)
assert default_failure_strategy.failure_strategy.on_failure == "stop"
with pytest.raises(ValueError, match="output name"):
DeclaredOutputConfig(name="bad-name", type=DeclaredOutputType.STRING)
with pytest.raises(ValueError, match="file metadata"):
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, file={})
with pytest.raises(ValueError, match="array_item is only allowed"):
DeclaredOutputConfig(
name="summary",
type=DeclaredOutputType.STRING,
array_item={"type": DeclaredOutputType.STRING},
)
with pytest.raises(ValueError, match="children is only allowed"):
DeclaredOutputConfig(
name="summary",
type=DeclaredOutputType.STRING,
children=[DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING)],
)
with pytest.raises(ValueError, match="output check is only allowed"):
DeclaredOutputConfig.model_validate(
{
"name": "summary",
"type": "string",
"check": {
"enabled": True,
"prompt": "Compare output",
"benchmark_file_ref": {"name": "expected.pdf"},
},
}
)
@@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes():
ComposerConfigValidator.validate_save_payload(payload)
def test_locked_workflow_node_job_only_allows_inline_soul_payload():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
"soul_lock": {"locked": True},
"agent_soul": {"prompt": {"system_prompt": "changed"}},
}
)
ComposerConfigValidator.validate_save_payload(payload)
def test_agent_app_soul_allows_app_features_and_variables():
payload = ComposerSavePayload.model_validate(
{
@@ -0,0 +1,123 @@
from datetime import UTC, datetime
from decimal import Decimal
from types import SimpleNamespace
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from models.enums import ConversationFromSource, MessageStatus
from services.agent.observability_service import AgentObservabilityService
def test_resolve_source_accepts_frontend_aliases() -> None:
assert AgentObservabilityService.resolve_source(None) is None
assert AgentObservabilityService.resolve_source("all") is None
assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE
assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API
assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP
with pytest.raises(ValueError, match="Unsupported source"):
AgentObservabilityService.resolve_source("unknown")
def test_serialize_log_message_returns_frontend_log_shape() -> None:
created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC)
updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC)
message = SimpleNamespace(
id="message-1",
conversation_id="conversation-1",
query="hello",
answer="hi",
error=None,
status=MessageStatus.NORMAL,
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="account-1",
message_tokens=3,
answer_tokens=4,
total_price=Decimal("0.0001"),
currency="USD",
provider_response_latency=1.25,
created_at=created_at,
updated_at=updated_at,
)
conversation = SimpleNamespace(name="Debug conversation")
payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type]
assert payload == {
"id": "message-1",
"message_id": "message-1",
"conversation_id": "conversation-1",
"conversation_name": "Debug conversation",
"query": "hello",
"answer": "hi",
"status": "success",
"error": None,
"source": "explore",
"from_source": "console",
"from_end_user_id": None,
"from_account_id": "account-1",
"message_tokens": 3,
"answer_tokens": 4,
"total_tokens": 7,
"total_price": "0.0001",
"currency": "USD",
"latency": 1.25,
"created_at": int(created_at.timestamp()),
"updated_at": int(updated_at.timestamp()),
}
def test_build_charts_and_summary_match_monitoring_metrics() -> None:
rows = [
{
"date": "2026-06-16",
"message_count": 2,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 30,
"total_price": Decimal("0.003"),
"avg_latency": 1.5,
"latency_sum": 3,
"answer_tokens": 12,
"like_count": 1,
},
{
"date": "2026-06-17",
"message_count": 1,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 20,
"total_price": Decimal("0.002"),
"avg_latency": 2,
"latency_sum": 2,
"answer_tokens": 8,
"like_count": 1,
},
]
charts = AgentObservabilityService._build_charts(rows)
summary = AgentObservabilityService._build_summary(rows)
assert charts["token_usage"] == [
{"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"},
{"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"},
]
assert charts["average_response_time"] == [
{"date": "2026-06-16", "latency": 1500.0},
{"date": "2026-06-17", "latency": 2000.0},
]
assert summary == {
"total_messages": 3,
"total_conversations": 2,
"total_end_users": 2,
"total_tokens": 50,
"total_price": "0.005",
"currency": "USD",
"average_session_interactions": 1.5,
"average_response_time": 1666.6667,
"tokens_per_second": 4.0,
"user_satisfaction_rate": 66.67,
}
@@ -17,10 +17,13 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentFileRefConfig,
DeclaredArrayItem,
DeclaredOutputChildConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from models.model import IconType
from models.workflow import Workflow
from services.agent import composer_service, roster_service
from services.agent.agent_soul_state import agent_soul_has_model
@@ -112,7 +115,7 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch):
effective = result["effective_declared_outputs"]
assert [o["name"] for o in effective] == ["text", "files", "json"]
files_output = next(o for o in effective if o["name"] == "files")
assert files_output["array_item"] == {"type": "file", "description": None}
assert files_output["array_item"] == {"type": "file", "description": None, "children": []}
def test_load_workflow_composer_serializes_existing_binding(monkeypatch):
@@ -457,6 +460,125 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch):
assert new_version_binding.current_snapshot_id == "new-version-1"
def test_node_job_only_updates_inline_agent_soul(monkeypatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
inline_agent = SimpleNamespace(
id="inline-agent-1",
scope=AgentScope.WORKFLOW_ONLY,
active_config_snapshot_id="inline-version-1",
active_config_has_model=False,
updated_by=None,
)
current_snapshot = AgentConfigSnapshot(
id="inline-version-1",
tenant_id="tenant-1",
agent_id="inline-agent-1",
version=1,
config_snapshot='{"prompt":{"system_prompt":"old"}}',
)
next_snapshot = AgentConfigSnapshot(
id="inline-version-2",
tenant_id="tenant-1",
agent_id="inline-agent-1",
version=2,
)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot)
monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent)
binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="inline-agent-1",
current_snapshot_id="inline-version-1",
)
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW.value,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
"agent_soul": {
"model": {
"plugin_id": "langgenius/openai/openai",
"model_provider": "openai",
"model": "gpt-4o",
},
"prompt": {"system_prompt": "new"},
},
"node_job": {"workflow_prompt": "use prior output"},
}
)
updated_binding = AgentComposerService._save_node_job_only(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
node_id="node-1",
account_id="account-1",
binding=binding,
payload=payload,
)
assert updated_binding.current_snapshot_id == "inline-version-2"
assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output"
assert updated_binding.updated_by == "account-1"
assert inline_agent.active_config_snapshot_id == "inline-version-2"
assert inline_agent.active_config_has_model is True
assert inline_agent.updated_by == "account-1"
def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
current_snapshot = AgentConfigSnapshot(
id="inline-version-1",
tenant_id="tenant-1",
agent_id="agent-1",
version=1,
config_snapshot='{"prompt":{"system_prompt":"old"}}',
)
next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2)
roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot)
monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="agent-1",
current_snapshot_id="inline-version-1",
)
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW.value,
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
"agent_soul": {"prompt": {"system_prompt": "new"}},
}
)
with pytest.raises(ValueError, match="workflow-only agent"):
AgentComposerService._save_node_job_only(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
node_id="node-1",
account_id="account-1",
binding=binding,
payload=payload,
)
def test_composer_create_agents_syncs_active_config_has_model(monkeypatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
@@ -649,6 +771,7 @@ def test_roster_list_and_invite_options(monkeypatch):
lambda version_ids: {"version-1": version, "version-2": unconfigured_version},
)
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: {"agent-1"})
listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20)
invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1")
@@ -661,6 +784,8 @@ def test_roster_list_and_invite_options(monkeypatch):
assert listed["data"][0]["created_at"] == int(created_at.timestamp())
assert listed["data"][0]["updated_at"] == int(updated_at.timestamp())
assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp())
assert listed["data"][0]["active_config_is_published"] is True
assert listed["data"][1]["active_config_is_published"] is False
assert invited["data"][0]["is_in_current_workflow"] is True
assert invited["data"][0]["existing_node_ids"] == ["node-1"]
@@ -690,6 +815,7 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch):
},
)
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: set())
result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1)
@@ -698,6 +824,41 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch):
assert [item["id"] for item in result["data"]] == ["agent-2"]
def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Published",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
)
draft_agent = Agent(
id="agent-2",
tenant_id="tenant-1",
name="Draft",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id=None,
)
service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]]))
flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent])
assert flags == {"agent-1": True, "agent-2": False}
assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True
assert AgentRosterService(FakeSession()).load_active_config_is_published_by_agent_id(
tenant_id="tenant-1",
agents=[draft_agent],
) == {"agent-2": False}
def test_published_references_include_app_display_fields_and_sort_by_updated_at():
recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC)
stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC)
@@ -1042,7 +1203,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
{
"env": {
"variables": [{"name": "MY_VAR", "value": "v"}],
"secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}],
"secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}],
},
"tools": {
"cli_tools": [
@@ -1051,7 +1212,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
"command": "apt-get install -y jq",
"env": {
"variables": [{"name": "JQ_COLOR", "value": "1"}],
"secret_refs": [{"name": "JQ_TOKEN", "id": "credential-2"}],
"secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}],
},
},
{
@@ -1067,8 +1228,10 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
)
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
assert config.env.secret_refs[0].value == "credential-1"
assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR"
assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN"
assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2"
class TestAgentAppBackingAgent:
@@ -1147,6 +1310,267 @@ class TestAgentAppBackingAgent:
with pytest.raises(roster_service.AgentNotFoundError):
service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x")
def test_duplicate_agent_app_copies_app_config_and_active_soul(self, monkeypatch):
source_config = SimpleNamespace(
opening_statement="hello",
suggested_questions='["q1"]',
suggested_questions_after_answer='{"enabled": true}',
speech_to_text='{"enabled": false}',
text_to_speech='{"enabled": false}',
more_like_this='{"enabled": false}',
model=None,
user_input_form=None,
dataset_query_variable=None,
pre_prompt=None,
agent_mode=None,
sensitive_word_avoidance=None,
retriever_resource='{"enabled": true}',
prompt_type="simple",
chat_prompt_config=None,
completion_prompt_config=None,
dataset_configs=None,
external_data_tools=None,
file_upload='{"image": {"enabled": true}}',
)
target_config = SimpleNamespace(**dict.fromkeys(AgentRosterService._APP_MODEL_CONFIG_COPY_FIELDS))
source_app = SimpleNamespace(
id="source-app",
tenant_id="tenant-1",
name="Iris",
description="source desc",
icon_type="emoji",
icon="robot",
icon_background="#fff",
api_rph=1,
api_rpm=2,
max_active_requests=3,
enable_site=False,
enable_api=True,
use_icon_as_answer_icon=True,
tracing="{}",
app_model_config=source_config,
)
target_app = SimpleNamespace(
id="target-app",
app_model_config=target_config,
enable_site=True,
enable_api=True,
use_icon_as_answer_icon=False,
tracing=None,
)
source_agent = Agent(
id="source-agent",
tenant_id="tenant-1",
name="Iris",
description="source desc",
role="Analyst",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
app_id="source-app",
active_config_snapshot_id="source-version",
active_config_has_model=True,
)
target_agent = Agent(
id="target-agent",
tenant_id="tenant-1",
name="Iris copy",
description="source desc",
role="Analyst",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
app_id="target-app",
active_config_snapshot_id="target-version",
)
source_version = AgentConfigSnapshot(
id="source-version",
tenant_id="tenant-1",
agent_id="source-agent",
version=1,
config_snapshot=_agent_soul_with_model(),
summary="configured",
version_note="v1",
created_by="account-1",
)
target_version = AgentConfigSnapshot(
id="target-version",
tenant_id="tenant-1",
agent_id="target-agent",
version=1,
config_snapshot=AgentSoulConfig(),
created_by="account-1",
)
session = FakeSession(
scalar=[source_agent, source_app, source_agent, target_agent, source_version, target_version],
scalars=[[]],
)
captured: dict[str, object] = {}
class FakeAppService:
def create_app(self, tenant_id: str, params, account: object) -> object:
captured["tenant_id"] = tenant_id
captured["params"] = params
captured["account"] = account
return target_app
monkeypatch.setattr(roster_service, "AppService", FakeAppService)
monkeypatch.setattr(
roster_service.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
account = SimpleNamespace(id="account-1")
duplicated = AgentRosterService(session).duplicate_agent_app(
tenant_id="tenant-1",
agent_id="source-agent",
account=account,
)
assert duplicated is target_app
params = captured["params"]
assert params.name == "Iris copy"
assert params.mode == "agent"
assert params.agent_role == "Analyst"
assert target_app.enable_site is False
assert target_app.enable_api is True
assert target_app.use_icon_as_answer_icon is True
assert target_app.tracing == "{}"
assert target_config.opening_statement == "hello"
assert target_config.file_upload == '{"image": {"enabled": true}}'
assert target_config.updated_by == "account-1"
assert target_version.config_snapshot.model.model == "gpt-4o"
assert target_version.summary == "configured"
assert target_version.version_note == "v1"
assert target_agent.active_config_has_model is True
assert target_agent.updated_by == "account-1"
assert session.commits == 1
def test_duplicate_agent_app_inherits_webapp_access_mode(self, monkeypatch):
source_app = SimpleNamespace(
id="source-app",
tenant_id="tenant-1",
name="Iris",
description="source desc",
icon_type=None,
icon="robot",
icon_background="#fff",
api_rph=1,
api_rpm=2,
max_active_requests=3,
enable_site=True,
enable_api=True,
use_icon_as_answer_icon=False,
tracing=None,
)
source_agent = SimpleNamespace(id="source-agent", role="Analyst")
target_app = SimpleNamespace(id="target-app")
session = FakeSession()
service = AgentRosterService(session)
monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app)
monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent)
monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None)
monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None)
monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy")
class FakeAppService:
def create_app(self, tenant_id: str, params, account: object) -> object:
return target_app
access_mode_updates = []
class FakeWebAppAuth:
@classmethod
def get_app_access_mode_by_id(cls, app_id: str) -> object:
return SimpleNamespace(access_mode="private")
@classmethod
def update_app_access_mode(cls, app_id: str, access_mode: str) -> None:
access_mode_updates.append((app_id, access_mode))
monkeypatch.setattr(roster_service, "AppService", FakeAppService)
monkeypatch.setattr(
roster_service.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)),
)
monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth)
duplicated = service.duplicate_agent_app(
tenant_id="tenant-1",
agent_id="source-agent",
account=SimpleNamespace(id="account-1"),
)
assert duplicated is target_app
assert access_mode_updates == [("target-app", "private")]
def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch):
source_app = SimpleNamespace(
id="source-app",
tenant_id="tenant-1",
name="Iris",
description="source desc",
icon_type=IconType.EMOJI,
icon="robot",
icon_background="#fff",
api_rph=1,
api_rpm=2,
max_active_requests=3,
enable_site=True,
enable_api=True,
use_icon_as_answer_icon=False,
tracing=None,
)
source_agent = SimpleNamespace(id="source-agent", role="Analyst")
target_app = SimpleNamespace(id="target-app")
session = FakeSession()
service = AgentRosterService(session)
monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app)
monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent)
monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None)
monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None)
monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy")
class FakeAppService:
def create_app(self, tenant_id: str, params, account: object) -> object:
return target_app
access_mode_updates = []
class FakeWebAppAuth:
@classmethod
def get_app_access_mode_by_id(cls, app_id: str) -> object:
raise ValueError("not found")
@classmethod
def update_app_access_mode(cls, app_id: str, access_mode: str) -> None:
access_mode_updates.append((app_id, access_mode))
monkeypatch.setattr(roster_service, "AppService", FakeAppService)
monkeypatch.setattr(
roster_service.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)),
)
monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth)
service.duplicate_agent_app(
tenant_id="tenant-1",
agent_id="source-agent",
account=SimpleNamespace(id="account-1"),
)
assert access_mode_updates == [("target-app", "public")]
def test_normalize_app_icon_type(self):
assert AgentRosterService._normalize_app_icon_type(None) is None
assert AgentRosterService._normalize_app_icon_type(IconType.EMOJI) == "emoji"
assert AgentRosterService._normalize_app_icon_type("image") == "image"
class TestListWorkflowsReferencingAppAgent:
def test_groups_bindings_by_workflow_app_and_sorts_by_name(self):
@@ -1263,7 +1687,22 @@ class TestWorkflowAgentDraftBindingSync:
node_job_config=WorkflowNodeJobConfig(
workflow_prompt="Summarize the upstream result.",
declared_outputs=[
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary")
DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"),
DeclaredOutputConfig(
name="profile",
type=DeclaredOutputType.OBJECT,
children=[
DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING),
DeclaredOutputChildConfig(
name="addresses",
type=DeclaredOutputType.ARRAY,
array_item=DeclaredArrayItem(
type=DeclaredOutputType.OBJECT,
children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)],
),
),
],
),
],
),
)
@@ -1279,6 +1718,9 @@ class TestWorkflowAgentDraftBindingSync:
assert node_data["agent_declared_outputs"][0]["name"] == "summary"
assert node_data["agent_declared_outputs"][0]["type"] == "string"
assert node_data["agent_declared_outputs"][0]["description"] == "Short summary"
profile_output = node_data["agent_declared_outputs"][1]
assert profile_output["children"][0]["name"] == "email"
assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city"
assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"]
def test_creates_roster_binding_from_agent_node_graph(self):
@@ -2114,6 +2556,117 @@ def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(
assert guarded["agent_id"] == "agent-1"
def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch):
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"save_strategy": "node_job_only",
"agent_soul": _drive_soul().model_dump(mode="json"),
"soul_lock": {"locked": False},
}
)
binding = WorkflowAgentNodeBinding(
tenant_id="t-1",
app_id="app-1",
workflow_id="wf-1",
workflow_version="draft",
node_id="n-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="agent-1",
current_snapshot_id="version-1",
)
monkeypatch.setattr(composer_service.db, "session", FakeSession())
monkeypatch.setattr(
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
)
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
)
monkeypatch.setattr(
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
)
monkeypatch.setattr(
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
)
guarded: dict[str, str] = {}
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
guarded["tenant_id"] = tenant_id
guarded["agent_id"] = agent_id
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
result = AgentComposerService.save_workflow_composer(
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
)
assert result == {"state": "ok", "validation": {"warnings": []}}
assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch):
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"save_strategy": "node_job_only",
"agent_soul": _drive_soul().model_dump(mode="json"),
"soul_lock": {"locked": False},
}
)
binding = WorkflowAgentNodeBinding(
tenant_id="t-1",
app_id="app-1",
workflow_id="wf-1",
workflow_version="draft",
node_id="n-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="version-1",
)
monkeypatch.setattr(composer_service.db, "session", FakeSession())
monkeypatch.setattr(
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
)
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
)
monkeypatch.setattr(
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
)
monkeypatch.setattr(
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
)
def fail_guard(cls, *, tenant_id, agent_id, agent_soul):
raise AssertionError("roster node-job-only saves must not validate agent drive refs")
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard))
result = AgentComposerService.save_workflow_composer(
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
)
assert result == {"state": "ok", "validation": {"warnings": []}}
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch):
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
@@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch
import pytest
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
run = MagicMock()
run.tenant_id = tenant_id
run.id = run_id
run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
return run
def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
return WorkflowRunCleanupRef(
id=run_id,
tenant_id=tenant_id,
created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
)
@pytest.fixture
@@ -341,28 +342,28 @@ class TestRunDeleteMode:
return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo)
def test_no_rows_stops_immediately(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
c = self._make_cleanup(mock_repo)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
def test_all_paid_skips_delete(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
c = self._make_cleanup(mock_repo)
# billing disabled -> all free; but let's override _filter_free_tenants to return empty
c._filter_free_tenants = MagicMock(return_value=set())
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
def test_runs_deleted_successfully(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.delete_runs_with_related.return_value = {
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
mock_repo.delete_runs_with_related_by_ids.return_value = {
"runs": 1,
"node_executions": 0,
"offloads": 0,
@@ -376,12 +377,12 @@ class TestRunDeleteMode:
cfg.BILLING_ENABLED = False
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"):
c.run()
mock_repo.delete_runs_with_related.assert_called_once()
mock_repo.delete_runs_with_related_by_ids.assert_called_once()
def test_delete_exception_reraises(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error")
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]]
mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error")
c = self._make_cleanup(mock_repo)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
@@ -389,7 +390,7 @@ class TestRunDeleteMode:
c.run()
def test_summary_with_window_start(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
cfg.BILLING_ENABLED = False
@@ -421,9 +422,10 @@ class TestRunDryRunMode:
)
def test_dry_run_no_delete_called(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.count_runs_with_related.return_value = {
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
mock_repo.count_runs_with_related_by_ids.return_value = {
"runs": 1,
"node_executions": 2,
"offloads": 0,
"app_logs": 0,
@@ -435,11 +437,11 @@ class TestRunDryRunMode:
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.count_runs_with_related.assert_called_once()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
mock_repo.count_runs_with_related_by_ids.assert_called_once()
def test_dry_run_summary_with_window_start(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
cfg.BILLING_ENABLED = False
@@ -454,14 +456,14 @@ class TestRunDryRunMode:
c.run()
def test_dry_run_all_paid_skips_count(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
c = self._make_dry_cleanup(mock_repo)
c._filter_free_tenants = MagicMock(return_value=set())
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.count_runs_with_related.assert_not_called()
mock_repo.count_runs_with_related_by_ids.assert_not_called()
# ---------------------------------------------------------------------------
@@ -492,7 +494,7 @@ class TestTriggerLogMethods:
# ---------------------------------------------------------------------------
# _count_node_executions / _delete_node_executions
# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids
# ---------------------------------------------------------------------------
@@ -500,25 +502,23 @@ class TestNodeExecutionMethods:
def test_count_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
runs = [make_run("t1", "r1")]
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.count_by_runs.return_value = (10, 2)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._count_node_executions(session, runs)
result = cleanup._count_node_executions_by_run_ids(session, ["r1"])
assert result == (10, 2)
def test_delete_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
runs = [make_run("t1", "r1")]
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.delete_by_runs.return_value = (5, 1)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._delete_node_executions(session, runs)
result = cleanup._delete_node_executions_by_run_ids(session, ["r1"])
assert result == (5, 1)
@@ -3,38 +3,27 @@ from typing import Any
import pytest
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
from services.billing_service import SubscriptionPlan
from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
class FakeRun:
def __init__(
self,
run_id: str,
tenant_id: str,
created_at: datetime.datetime,
app_id: str = "app-1",
workflow_id: str = "wf-1",
triggered_from: str = "workflow-run",
) -> None:
self.id = run_id
self.tenant_id = tenant_id
self.app_id = app_id
self.workflow_id = workflow_id
self.triggered_from = triggered_from
self.created_at = created_at
def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef:
return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
class FakeRepo:
def __init__(
self,
batches: list[list[FakeRun]],
batches: list[list[WorkflowRunCleanupRef]],
delete_result: dict[str, int] | None = None,
count_result: dict[str, int] | None = None,
) -> None:
self.batches = batches
self.call_idx = 0
self.candidate_call_idx = 0
self.last_candidate_batch: list[WorkflowRunCleanupRef] = []
self.cleanup_ref_calls: list[dict[str, object]] = []
self.deleted: list[list[str]] = []
self.counted: list[list[str]] = []
self.delete_result = delete_result or {
@@ -56,7 +45,7 @@ class FakeRepo:
"pause_reasons": 0,
}
def get_runs_batch_by_time_range(
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
@@ -65,27 +54,50 @@ class FakeRepo:
run_types=None,
tenant_ids=None,
workflow_ids=None,
) -> list[FakeRun]:
if self.call_idx >= len(self.batches):
upper_bound: tuple[datetime.datetime, str] | None = None,
) -> list[WorkflowRunCleanupRef]:
self.cleanup_ref_calls.append(
{
"start_from": start_from,
"end_before": end_before,
"last_seen": last_seen,
"batch_size": batch_size,
"run_types": run_types,
"tenant_ids": tenant_ids,
"workflow_ids": workflow_ids,
"upper_bound": upper_bound,
}
)
if tenant_ids is not None or upper_bound is not None:
refs = self.last_candidate_batch
if tenant_ids is not None:
tenant_id_set = set(tenant_ids)
refs = [ref for ref in refs if ref.tenant_id in tenant_id_set]
if upper_bound is not None:
refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound]
return refs[:batch_size]
if self.candidate_call_idx >= len(self.batches):
return []
batch = self.batches[self.call_idx]
self.call_idx += 1
batch = self.batches[self.candidate_call_idx]
self.candidate_call_idx += 1
self.last_candidate_batch = batch
return batch
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
self.deleted.append([run.id for run in runs])
self.deleted.append(list(run_ids))
result = self.delete_result.copy()
result["runs"] = len(runs)
result["runs"] = len(run_ids)
return result
def count_runs_with_related(
self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None
def count_runs_with_related_by_ids(
self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None
) -> dict[str, int]:
self.counted.append([run.id for run in runs])
self.counted.append(list(run_ids))
result = self.count_result.copy()
result["runs"] = len(runs)
result["runs"] = len(run_ids)
return result
@@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
repo = FakeRepo(
batches=[
[
FakeRun("run-free", "t_free", cutoff),
FakeRun("run-paid", "t_paid", cutoff),
make_ref("run-free", "t_free", cutoff),
make_ref("run-paid", "t_paid", cutoff),
]
]
)
@@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
cleanup.run()
assert repo.deleted == [["run-free"]]
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[
[
make_ref("run-free", "t_free", cutoff),
make_ref("run-paid", "t_paid", cutoff),
]
]
)
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
billing_calls: list[list[str]] = []
def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]:
billing_calls.append(tenant_ids)
return {
"t_free": plan_info("sandbox", -1),
"t_paid": plan_info("team", -1),
}
monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk))
cleanup.run()
assert billing_calls == [["t_free", "t_paid"]]
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
assert repo.deleted == [["run-free"]]
def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]])
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
@@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None
cleanup.run()
assert repo.deleted == []
assert len(repo.cleanup_ref_calls) == 2
def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
monkeypatch.setattr(
cleanup_module.BillingService,
"get_plan_bulk_with_cache",
staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}),
)
batch_calls: list[dict[str, object]] = []
monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs))
cleanup.run()
assert repo.deleted == []
assert repo.counted == []
assert batch_calls[0]["batch_rows"] == 1
assert batch_calls[0]["targeted_runs"] == 0
assert batch_calls[0]["skipped_runs"] == 1
assert batch_calls[0]["deleted_runs"] == 0
def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None:
first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0)
second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0)
repo = FakeRepo(
batches=[
[
make_ref("run-free-1", "t_free", first_created_at),
make_ref("run-free-2", "t_free", second_created_at),
]
]
)
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
cleanup.run()
assert repo.cleanup_ref_calls[1]["last_seen"] is None
assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2")
assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2")
def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[[FakeRun("run-free", "t_free", cutoff)]],
batches=[[make_ref("run-free", "t_free", cutoff)]],
delete_result={
"runs": 0,
"node_executions": 2,
@@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None
def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingRepo(FakeRepo):
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
raise RuntimeError("delete failed")
cutoff = datetime.datetime.now()
repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]])
repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
@@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[[FakeRun("run-free", "t_free", cutoff)]],
batches=[[make_ref("run-free", "t_free", cutoff)]],
count_result={
"runs": 0,
"node_executions": 2,
@@ -296,6 +296,64 @@ class TestBillingSandboxPolicyFilterMessageIds:
called_tenant_ids = set(plan_provider.call_args[0][0])
assert called_tenant_ids == {"tenant1", "tenant2"}
def test_plan_provider_reuses_job_level_cache(self):
"""Test that repeated tenants are not fetched from billing more than once."""
# Arrange
now = self.CURRENT_TIMESTAMP
tenant_plans = {
"tenant1": {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
"tenant2": {"plan": CloudPlan.SANDBOX, "expiration_date": -1},
}
plan_provider = MagicMock(
side_effect=lambda tenant_ids: {tenant_id: tenant_plans[tenant_id] for tenant_id in tenant_ids}
)
policy = BillingSandboxPolicy(
plan_provider=plan_provider,
graceful_period_days=self.GRACEFUL_PERIOD_DAYS,
current_timestamp=now,
)
# Act
first_result = policy.filter_message_ids(
[make_simple_message("msg1", "app1")],
{"app1": "tenant1"},
)
second_result = policy.filter_message_ids(
[
make_simple_message("msg2", "app1"),
make_simple_message("msg3", "app2"),
],
{"app1": "tenant1", "app2": "tenant2"},
)
# Assert
assert set(first_result) == {"msg1"}
assert set(second_result) == {"msg2", "msg3"}
assert plan_provider.call_count == 2
assert set(plan_provider.call_args_list[0].args[0]) == {"tenant1"}
assert set(plan_provider.call_args_list[1].args[0]) == {"tenant2"}
def test_whitelisted_tenants_are_not_fetched_from_plan_provider(self):
"""Test that whitelisted tenants are skipped before billing plan lookup."""
# Arrange
plan_provider = make_plan_provider({})
policy = BillingSandboxPolicy(
plan_provider=plan_provider,
graceful_period_days=self.GRACEFUL_PERIOD_DAYS,
tenant_whitelist=["tenant1"],
current_timestamp=self.CURRENT_TIMESTAMP,
)
# Act
result = policy.filter_message_ids(
[make_simple_message("msg1", "app1")],
{"app1": "tenant1"},
)
# Assert
assert list(result) == []
plan_provider.assert_not_called()
def test_complex_mixed_scenario(self):
"""Test complex scenario with mixed plans, expirations, whitelist, and missing mappings."""
# Arrange
@@ -497,6 +555,22 @@ class TestMessagesCleanServiceFromTimeRange:
batch_size=-100,
)
with pytest.raises(ValueError, match="max_candidate_batch_size .* must be greater than 0"):
MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
max_candidate_batch_size=0,
)
with pytest.raises(ValueError, match="delete_batch_size .* must be greater than 0"):
MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
delete_batch_size=0,
)
def test_valid_params_creates_instance(self):
"""Test that valid parameters create a correctly configured instance."""
# Arrange
@@ -512,6 +586,8 @@ class TestMessagesCleanServiceFromTimeRange:
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
max_candidate_batch_size=5000,
delete_batch_size=200,
dry_run=dry_run,
)
@@ -521,6 +597,9 @@ class TestMessagesCleanServiceFromTimeRange:
assert service._start_from == start_from
assert service._end_before == end_before
assert service._batch_size == batch_size
assert service._candidate_batch_size == batch_size
assert service._max_candidate_batch_size == 5000
assert service._delete_batch_size == 200
assert service._dry_run == dry_run
def test_default_params(self):
@@ -539,6 +618,9 @@ class TestMessagesCleanServiceFromTimeRange:
# Assert
assert service._batch_size == 1000 # default
assert service._candidate_batch_size == 1000 # default
assert service._max_candidate_batch_size == 1000 # default
assert service._delete_batch_size == 1000 # default
assert service._dry_run is False # default
def test_explicit_task_label(self):
@@ -590,6 +672,12 @@ class TestMessagesCleanServiceFromDays:
with pytest.raises(ValueError, match="batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, batch_size=-500)
with pytest.raises(ValueError, match="max_candidate_batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, max_candidate_batch_size=0)
with pytest.raises(ValueError, match="delete_batch_size .* must be greater than 0"):
MessagesCleanService.from_days(policy=policy, days=30, delete_batch_size=0)
def test_valid_params_creates_instance(self):
"""Test that valid parameters create a correctly configured instance."""
# Arrange
@@ -606,6 +694,8 @@ class TestMessagesCleanServiceFromDays:
policy=policy,
days=days,
batch_size=batch_size,
max_candidate_batch_size=2500,
delete_batch_size=250,
dry_run=dry_run,
)
@@ -616,6 +706,9 @@ class TestMessagesCleanServiceFromDays:
assert service._start_from is None
assert service._end_before == expected_end_before
assert service._batch_size == batch_size
assert service._candidate_batch_size == batch_size
assert service._max_candidate_batch_size == 2500
assert service._delete_batch_size == 250
assert service._dry_run == dry_run
def test_default_params(self):
@@ -637,6 +730,92 @@ class TestMessagesCleanServiceFromDays:
assert service._metrics._base_attributes["task_label"] == "custom"
class TestMessagesCleanServiceBatchHelpers:
"""Unit tests for cache and adaptive batch helpers."""
def test_load_app_to_tenant_mapping_reuses_cache(self):
class ExecuteResult:
def __init__(self, rows: list[tuple[str, str]]) -> None:
self._rows = rows
def all(self) -> list[tuple[str, str]]:
return self._rows
class FakeSession:
execute_calls: int
def __init__(self) -> None:
self.execute_calls = 0
def execute(self, _stmt: object) -> ExecuteResult:
self.execute_calls += 1
return ExecuteResult([("app1", "tenant1")])
session = FakeSession()
cache: dict[str, str | None] = {}
first_mapping, first_cache_misses, first_found = MessagesCleanService._load_app_to_tenant_mapping(
session=session, # type: ignore[arg-type]
app_ids=["app1"],
app_to_tenant_cache=cache,
)
second_mapping, second_cache_misses, second_found = MessagesCleanService._load_app_to_tenant_mapping(
session=session, # type: ignore[arg-type]
app_ids=["app1"],
app_to_tenant_cache=cache,
)
assert first_mapping == {"app1": "tenant1"}
assert second_mapping == {"app1": "tenant1"}
assert first_cache_misses == 1
assert first_found == 1
assert second_cache_misses == 0
assert second_found == 0
assert session.execute_calls == 1
def test_candidate_batch_size_grows_for_low_hit_rate(self):
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=1000,
max_candidate_batch_size=50000,
delete_batch_size=1000,
)
smoothed_hit_rate, next_batch_size = service._adjust_candidate_batch_size(
smoothed_hit_rate=None,
candidate_count=10000,
eligible_count=55,
)
assert smoothed_hit_rate == 0.0055
assert next_batch_size == 50000
def test_candidate_batch_size_shrinks_when_hit_rate_is_high(self):
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=10000,
max_candidate_batch_size=50000,
delete_batch_size=1000,
)
_smoothed_hit_rate, next_batch_size = service._adjust_candidate_batch_size(
smoothed_hit_rate=None,
candidate_count=10000,
eligible_count=10000,
)
assert next_batch_size == 1000
def test_iter_message_id_chunks_uses_delete_batch_size(self):
chunks = list(MessagesCleanService._iter_message_id_chunks(["msg1", "msg2", "msg3", "msg4", "msg5"], 2))
assert chunks == [["msg1", "msg2"], ["msg3", "msg4"], ["msg5"]]
class TestMessagesCleanServiceRun:
"""Unit tests for MessagesCleanService.run instrumentation behavior."""
@@ -1,4 +1,5 @@
import json
import logging
from unittest.mock import Mock, patch
import pytest
@@ -468,16 +469,14 @@ class TestRagPipelineTaskProxy:
# Assert
proxy._dispatch.assert_called_once()
@patch("services.rag_pipeline.rag_pipeline_task_proxy.logger")
def test_delay_method_with_empty_entities(self, mock_logger):
def test_delay_method_with_empty_entities(self, caplog):
"""Test delay method with empty rag_pipeline_invoke_entities."""
# Arrange
proxy = RagPipelineTaskProxy("tenant-123", "user-456", [])
# Act
proxy.delay()
with caplog.at_level(logging.WARNING, logger="services.rag_pipeline.rag_pipeline_task_proxy"):
proxy.delay()
# Assert
mock_logger.warning.assert_called_once_with(
"Received empty rag pipeline invoke entities, no tasks delivered: %s %s", "tenant-123", "user-456"
)
assert "Received empty rag pipeline invoke entities, no tasks delivered: tenant-123 user-456" in caplog.text
@@ -50,8 +50,7 @@ class TestDeleteDraftVariableOffloadData:
assert result == 0
mock_conn.execute.assert_not_called()
@patch("tasks.remove_app_and_related_data_task.logging")
def test_delete_draft_variable_offload_data_database_failure(self, mock_logging):
def test_delete_draft_variable_offload_data_database_failure(self, caplog):
"""Test handling of database operation failures."""
mock_conn = MagicMock()
file_ids = ["file-1"]
@@ -60,13 +59,14 @@ class TestDeleteDraftVariableOffloadData:
mock_conn.execute.side_effect = Exception("Database error")
# Execute function - should not raise, but log error
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
with caplog.at_level(logging.ERROR):
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
# Should return 0 when error occurs
assert result == 0
# Verify error was logged
mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:")
assert "Error deleting draft variable offload data:" in caplog.text
class TestDeleteWorkflowArchiveLogs:
@@ -37,7 +37,6 @@ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { ZERO } from '@/util/uuid.js'
import {
assertErrorEnvelope,
assertExitCode,
assertNoAnsi,
assertNonZeroExit,
} from '../../helpers/assert.js'
@@ -216,147 +215,6 @@ describe('E2E / error message standards (spec 5.3)', () => {
expect(result.stderr).not.toContain(sentValue)
})
// ── 5.70d-h ErrorBody contract — error.server structure and rendering priorities ──
// PR #37285 introduces canonical ErrorBody on every /openapi/v1 non-2xx response.
// CLI strict-parses via zErrorBody.safeParse; success → full struct at error.server.
//
// V2 rendering priorities (format.ts, verified against codebase):
// header code : server?.code ?? cliCode — server wins, CLI fallback
// hint : cliHint ?? server?.hint — CLI wins, server fallback (V2 correction)
// details : server?.details[] — " - loc: msg (type)" per entry, no -v
it('[P0] 5.70d JSON envelope contains error.server with canonical code/status/message', async () => {
// Trigger: describe app ZERO — server returns canonical 404 ErrorBody
// { code:"not_found", status:404, message:"app not found" }.
// zErrorBody.safeParse succeeds → error.server is populated on the current server.
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
assertNonZeroExit(result)
const envelope = JSON.parse(result.stderr.trim()) as {
error: { code: string, server?: { code: string, status: number, message: string } }
}
expect(envelope.error.server, 'error.server must be present when server returns canonical ErrorBody').toBeDefined()
expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string')
expect(envelope.error.server?.code.length).toBeGreaterThan(0)
expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe('number')
expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe('string')
expect(envelope.error.server?.message.length).toBeGreaterThan(0)
})
it('[P1] 5.70e @accepts query validation returns canonical 422 with details array', async () => {
// Trigger: direct fetch to GET /apps?page=not-integer — @accepts(query=AppListQuery)
// validates page as int and emits canonical 422 ErrorBody with details[].
// Direct fetch is used because the CLI validates --page as integer client-side
// (would exit 2 before hitting the server); this pins the server-side contract.
const res = await fetch(
`${E.host.replace(/\/$/, '')}/openapi/v1/apps?workspace_id=${E.workspaceId}&page=not-an-integer`,
{ headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) },
)
expect(res.status).toBe(422)
const body = await res.json() as {
code?: string
status?: number
details?: Array<{ type: string, loc: Array<string | number>, msg: string }>
}
expect(body.code).toBe('invalid_param')
expect(body.status).toBe(422)
expect(Array.isArray(body.details), 'details must be an array').toBe(true)
expect(body.details!.length).toBeGreaterThan(0)
const entry = body.details![0]!
expect(typeof entry.type).toBe('string')
expect(typeof entry.msg).toBe('string')
expect(Array.isArray(entry.loc)).toBe(true)
})
it('[P1] 5.70g rendering priority — header code: server code wins over CLI classification code', async () => {
// renderHuman: headerCode = server?.code ?? e.code (server wins, V2 unchanged)
// When canonical ErrorBody is parsed, the server semantic code replaces the CLI
// classification code ("server_4xx_other") in the human-readable output header.
// Trigger: describe app ZERO → canonical 404; header starts with "not_found:".
const result = await fx.r(['describe', 'app', ZERO])
assertNonZeroExit(result)
expect(result.stderr.trimStart()).not.toMatch(/^server_4xx_other:/)
expect(result.stderr.trimStart()).toMatch(/^not_found:/)
})
it('[P1] 5.70g2 rendering priority — hint: CLI hint wins over server hint (V2 correction)', async () => {
// renderHuman: hint = cliHint ?? server?.hint (CLI wins — V2 spec correction)
// V1 incorrectly documented "server wins"; V2 aligns with codebase: CLI wins.
// Test: 401 AuthExpired — classifyResponse sets c.hint = AUTH_LOGIN_HINT before
// serverError is parsed; CLI hint takes precedence over any server-provided hint.
// Verified on current server (no @accepts deployment required).
const unauthTmp = await withTempConfig()
try {
const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir })
assertExitCode(result, 4)
const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } }
expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch(/auth login/i)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] 5.70h JSON envelope: error.code = CLI classification; error.server.code = server semantic code', async () => {
// toEnvelope() sets error.code from HTTP status bucket (e.g. "server_4xx_other")
// while the server's semantic code is separate in error.server.code.
// Agents can branch on error.server.code without parsing human-readable text.
// Trigger: describe app ZERO → canonical 404; error.code="server_4xx_other",
// error.server.code="not_found" — always distinct when ErrorBody is present.
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
assertNonZeroExit(result)
const envelope = JSON.parse(result.stderr.trim()) as {
error: { code: string, server?: { code: string } }
}
expect(envelope.error.code).toBe('server_4xx_other')
expect(envelope.error.server?.code).toBeDefined()
expect(envelope.error.server?.code).not.toBe('server_4xx_other')
})
// ── 5.70i / 5.70j PR #37285 boundary contract ───────────────────────────
it('[P1] 5.70i unknown /openapi/v1 route returns canonical 404 ErrorBody without route suggestions', async () => {
// PR #37285: ExternalApi._help_on_404 suppresses flask-restx route enumeration.
// Previously, an unknown path under /openapi/v1/ returned flask-restx's default
// 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table.
// After the fix it must return a canonical ErrorBody and contain no suggestions.
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, {
headers: { Authorization: `Bearer ${E.token}` },
signal: AbortSignal.timeout(8_000),
})
expect(res.status).toBe(404)
const body = await res.json() as Record<string, unknown>
// canonical ErrorBody fields must be present
expect(typeof body.code, '404 body must have a string code field').toBe('string')
expect(body.status, '404 body must have status: 404').toBe(404)
// no flask-restx route enumeration in the response
const raw = JSON.stringify(body)
expect(raw).not.toMatch(/did you mean/i)
expect(raw).not.toMatch(/you might want/i)
})
it('[P1] 5.70j device-flow 4xx uses RFC 8628 format, not ErrorBody — zErrorBody parse fails gracefully', async () => {
// PR #37285 explicitly excludes RFC 8628 device-flow endpoints from the
// ErrorBody contract. This test pins that contract:
// - The device/token endpoint returns RFC 8628 {error: string} on failure,
// not the canonical {code, status, message} shape.
// - When the CLI's classifyResponse encounters this, zErrorBody.safeParse
// returns failure → serverError = undefined → generic status-based message,
// no error.server field, no crash.
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code: 'fake-invalid-device-code-e2e-test', client_id: 'difyctl' }),
signal: AbortSignal.timeout(8_000),
})
// device flow errors are 4xx (400 bad_request or 401 expired_token etc.)
expect(res.status).toBeGreaterThanOrEqual(400)
expect(res.status).toBeLessThan(500)
const body = await res.json() as Record<string, unknown>
// RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair
expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string')
expect(body).not.toHaveProperty('status')
// zErrorBody.safeParse would fail → CLI sets serverError = undefined → generic message
})
// ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ────────
it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => {
-407
View File
@@ -1,407 +0,0 @@
/**
* E2E: difyctl get/create/delete/set member Member Management
*
* Data lifecycle:
* beforeAll generates two random emails, invites them as test fixtures
* afterAll removes both fixtures (best-effort)
*
* Email format: auto_test+<timestamp>@dify.ai
* No extra env vars required.
*
* JSON response shape (MemberListResponse):
* { page, limit, total, has_more, data: MemberResponse[] }
*
* MemberResponse fields:
* { id, name, email, role, status, avatar?, current: bool }
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertNonZeroExit,
} from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
// ── Fixture state ─────────────────────────────────────────────────────────────
let fx: AuthFixture
/** ID of the member used by get / set tests. */
let testMemberId: string
/** ID of the member reserved for the delete-success test. */
let deleteTargetId: string
const ts = Date.now()
const memberEmail = `auto_test+${ts}@dify.ai`
const deleteTargetEmail = `auto_test+${ts + 1}@dify.ai`
// ── Response type helpers ─────────────────────────────────────────────────────
type MemberItem = {
id: string
name: string
email: string
role: string
status: string
current?: boolean
}
type MemberListJson = {
data: MemberItem[]
total: number
page: number
limit: number
has_more: boolean
}
// ── Setup / teardown ──────────────────────────────────────────────────────────
beforeAll(async () => {
fx = await withAuthFixture(E)
// Invite the main test member; capture member_id from response
const createMain = await fx.r([
'create',
'member',
'--email',
memberEmail,
'--role',
'normal',
'-o',
'json',
])
if (createMain.exitCode !== 0) {
throw new Error(
`beforeAll: failed to create test member (${memberEmail}): ${createMain.stderr}`,
)
}
const mainData = JSON.parse(createMain.stdout.trim()) as { member_id?: string }
testMemberId = mainData.member_id as string
if (!testMemberId)
throw new Error(`beforeAll: missing member_id in: ${createMain.stdout}`)
// Invite the delete-target member
const createTarget = await fx.r([
'create',
'member',
'--email',
deleteTargetEmail,
'--role',
'normal',
'-o',
'json',
])
if (createTarget.exitCode !== 0) {
throw new Error(
`beforeAll: failed to create delete-target member (${deleteTargetEmail}): ${createTarget.stderr}`,
)
}
const targetData = JSON.parse(createTarget.stdout.trim()) as { member_id?: string }
deleteTargetId = targetData.member_id as string
if (!deleteTargetId)
throw new Error(`beforeAll: missing member_id in: ${createTarget.stdout}`)
})
afterAll(async () => {
if (testMemberId) {
await fx.r(['delete', 'member', testMemberId, '--yes']).catch(() => {})
}
if (deleteTargetId) {
await fx.r(['delete', 'member', deleteTargetId, '--yes']).catch(() => {})
}
await fx.cleanup()
})
// ── get member ────────────────────────────────────────────────────────────────
describe('E2E / difyctl get member', () => {
it('[P0] member list contains the created test member', async () => {
const result = await fx.r(['get', 'member', '-o', 'json'])
assertExitCode(result, 0)
const data = assertJson<MemberListJson>(result)
const ids = (data.data ?? []).map(m => m.id)
expect(ids, `testMemberId ${testMemberId} must appear in member list`).toContain(testMemberId)
})
it('[P0] default table output contains required column headers', async () => {
const result = await fx.r(['get', 'member'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/\bID\b/)
expect(result.stdout).toMatch(/\bNAME\b/)
expect(result.stdout).toMatch(/\bEMAIL\b/)
expect(result.stdout).toMatch(/\bROLE\b/)
expect(result.stdout).toMatch(/\bSTATUS\b/)
})
it('[P0] authenticated account appears in member list', async () => {
// The token owner ([email protected]) must appear in the member list
const result = await fx.r(['get', 'member', '-o', 'json'])
assertExitCode(result, 0)
const data = assertJson<MemberListJson>(result)
const ownerRow = data.data.find(m => m.email === E.email)
expect(ownerRow, `owner email ${E.email} must be in member list`).toBeDefined()
expect(ownerRow?.role).toBe('owner')
expect(ownerRow?.status).toBe('active')
})
it('[P0] -o json returns valid JSON with data array', async () => {
const result = await fx.r(['get', 'member', '-o', 'json'])
assertExitCode(result, 0)
const data = assertJson<MemberListJson>(result)
expect(Array.isArray(data.data), 'data must be an array').toBe(true)
expect(data.data.length).toBeGreaterThan(0)
})
it('[P0] -o json each member has id, email, role, status fields', async () => {
const result = await fx.r(['get', 'member', '-o', 'json'])
assertExitCode(result, 0)
const data = assertJson<MemberListJson>(result)
const member = data.data[0]!
expect(typeof member.id).toBe('string')
expect(typeof member.email).toBe('string')
expect(typeof member.role).toBe('string')
expect(typeof member.status).toBe('string')
})
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
const result = await fx.r(['get', 'member'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
it('[P0] unauthenticated get member returns auth error (exit code 4)', async () => {
const tmp = await withTempConfig()
try {
const result = await run(['get', 'member'], { configDir: tmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await tmp.cleanup()
}
})
it('[P1] -o yaml returns valid YAML (non-empty, no JSON braces)', async () => {
const result = await fx.r(['get', 'member', '-o', 'yaml'])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
})
it('[P1] -o json output is pipe-friendly (no ANSI, ends with newline)', async () => {
const result = await fx.r(['get', 'member', '-o', 'json'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] -w overrides the workspace', async () => {
const result = await fx.r(['get', 'member', '-w', E.workspaceId, '-o', 'json'])
assertExitCode(result, 0)
const data = assertJson<MemberListJson>(result)
expect(Array.isArray(data.data)).toBe(true)
})
})
// ── set member ────────────────────────────────────────────────────────────────
describe('E2E / difyctl set member', () => {
it('[P0] owner/admin can promote normal → admin', async () => {
const result = await fx.r(['set', 'member', testMemberId, '--role', 'admin', '-o', 'json'])
assertExitCode(result, 0)
const list = await fx.r(['get', 'member', '-o', 'json'])
const data = assertJson<MemberListJson>(list)
const updated = data.data.find(m => m.id === testMemberId)
expect(updated?.role).toBe('admin')
})
it('[P0] owner/admin can demote admin → normal', async () => {
await fx.r(['set', 'member', testMemberId, '--role', 'admin'])
const result = await fx.r(['set', 'member', testMemberId, '--role', 'normal', '-o', 'json'])
assertExitCode(result, 0)
const list = await fx.r(['get', 'member', '-o', 'json'])
const data = assertJson<MemberListJson>(list)
const updated = data.data.find(m => m.id === testMemberId)
expect(updated?.role).toBe('normal')
})
it('[P0] --role owner is rejected client-side (exit 2, no API call)', async () => {
const result = await fx.r(['set', 'member', testMemberId, '--role', 'owner'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/invalid|role|owner/i)
})
it('[P0] missing --role returns usage error', async () => {
const result = await fx.r(['set', 'member', testMemberId])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/role|required|missing/i)
})
it('[P0] unauthenticated set member returns auth error (exit 4)', async () => {
const tmp = await withTempConfig()
try {
const result = await run(['set', 'member', testMemberId, '--role', 'normal'], {
configDir: tmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await tmp.cleanup()
}
})
it('[P1] missing member-id returns usage error', async () => {
const result = await fx.r(['set', 'member', '--role', 'normal'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing|required|arg|member/i)
})
it('[P1] non-existent member-id returns server error', async () => {
const result = await fx.r([
'set',
'member',
'00000000-0000-0000-0000-000000000000',
'--role',
'normal',
])
assertNonZeroExit(result)
expect(result.stderr.trim().length).toBeGreaterThan(0)
})
})
// ── create member — error paths ───────────────────────────────────────────────
describe('E2E / difyctl create member (error paths)', () => {
it('[P0] --role with invalid value is rejected client-side (exit 2)', async () => {
const result = await fx.r([
'create',
'member',
'--email',
`auto_test+unused${Date.now()}@dify.ai`,
'--role',
'superadmin',
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/invalid|role/i)
})
it('[P0] --role owner is rejected client-side (exit 2)', async () => {
const result = await fx.r([
'create',
'member',
'--email',
`auto_test+unused${Date.now()}@dify.ai`,
'--role',
'owner',
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/invalid|role|owner/i)
})
it('[P0] missing --email returns usage error', async () => {
const result = await fx.r(['create', 'member', '--role', 'normal'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/email|required|missing/i)
})
it('[P0] missing --role returns usage error', async () => {
const result = await fx.r(['create', 'member', '--email', memberEmail])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/role|required|missing/i)
})
it('[P0] unauthenticated create member returns auth error (exit 4)', async () => {
const tmp = await withTempConfig()
try {
const result = await run(
['create', 'member', '--email', `auto_test+unauth${Date.now()}@dify.ai`, '--role', 'normal'],
{ configDir: tmp.configDir },
)
assertExitCode(result, 4)
}
finally {
await tmp.cleanup()
}
})
})
// ── delete member ─────────────────────────────────────────────────────────────
describe('E2E / difyctl delete member', () => {
it('[P0] owner/admin can remove a member from the workspace', async () => {
const result = await fx.r(['delete', 'member', deleteTargetId, '--yes'])
assertExitCode(result, 0)
const list = await fx.r(['get', 'member', '-o', 'json'])
const data = assertJson<MemberListJson>(list)
const ids = data.data.map(m => m.id)
expect(ids).not.toContain(deleteTargetId)
deleteTargetId = ''
})
it('[P0] attempting to delete self returns server error', async () => {
const list = await fx.r(['get', 'member', '-o', 'json'])
const data = assertJson<MemberListJson>(list)
const self = data.data.find(m => m.email === E.email)
if (!self) {
console.warn('[E2E] could not identify self in member list — skipping')
return
}
const result = await fx.r(['delete', 'member', self.id, '--yes'])
assertNonZeroExit(result)
expect(result.stderr).toMatch(/self|yourself|cannot|not.*allow|400|forbidden/i)
})
it('[P0] missing member-id argument returns usage error', async () => {
const result = await fx.r(['delete', 'member'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing|required|arg|member/i)
})
it('[P0] unauthenticated delete member returns auth error (exit 4)', async () => {
const tmp = await withTempConfig()
try {
const result = await run(
['delete', 'member', '00000000-0000-0000-0000-000000000000', '--yes'],
{ configDir: tmp.configDir },
)
assertExitCode(result, 4)
}
finally {
await tmp.cleanup()
}
})
it('[P1] non-existent member-id returns server error', async () => {
const result = await fx.r([
'delete',
'member',
'00000000-0000-0000-0000-000000000000',
'--yes',
])
assertNonZeroExit(result)
expect(result.stderr.trim().length).toBeGreaterThan(0)
})
it('[P1] -o json outputs structured envelope on error', async () => {
const result = await fx.r([
'delete',
'member',
'00000000-0000-0000-0000-000000000000',
'--yes',
'-o',
'json',
])
assertNonZeroExit(result)
assertErrorEnvelope(result)
})
})
-2
View File
@@ -92,8 +92,6 @@ export default defineConfig({
'test/e2e/suites/framework/**/*.e2e.ts',
// discovery (get app / describe app)
'test/e2e/suites/discovery/**/*.e2e.ts',
// member management (get/create/delete/set member)
'test/e2e/suites/member/**/*.e2e.ts',
// dsl (export / import)
'test/e2e/suites/dsl/**/*.e2e.ts',
// run tests (require valid token)
-1
View File
@@ -135,7 +135,6 @@ AMPLITUDE_API_KEY=
TEXT_GENERATION_TIMEOUT_MS=60000
CSP_WHITELIST=
ALLOW_EMBED=false
ALLOW_INLINE_STYLES=false
ALLOW_UNSAFE_DATA_SCHEME=false
TOP_K_MAX_VALUE=10
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
-1
View File
@@ -387,7 +387,6 @@ services:
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
-1
View File
@@ -393,7 +393,6 @@ services:
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
-5
View File
@@ -192,11 +192,6 @@
"count": 1
}
},
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(shareLayout)/components/authenticated-layout.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -29,6 +29,9 @@ import {
zGetAgentByAgentIdDriveFilesPreviewResponse,
zGetAgentByAgentIdDriveFilesQuery,
zGetAgentByAgentIdDriveFilesResponse,
zGetAgentByAgentIdLogsPath,
zGetAgentByAgentIdLogsQuery,
zGetAgentByAgentIdLogsResponse,
zGetAgentByAgentIdMessagesByMessageIdPath,
zGetAgentByAgentIdMessagesByMessageIdResponse,
zGetAgentByAgentIdPath,
@@ -41,6 +44,9 @@ import {
zGetAgentByAgentIdSandboxFilesReadQuery,
zGetAgentByAgentIdSandboxFilesReadResponse,
zGetAgentByAgentIdSandboxFilesResponse,
zGetAgentByAgentIdStatisticsSummaryPath,
zGetAgentByAgentIdStatisticsSummaryQuery,
zGetAgentByAgentIdStatisticsSummaryResponse,
zGetAgentByAgentIdVersionsByVersionIdPath,
zGetAgentByAgentIdVersionsByVersionIdResponse,
zGetAgentByAgentIdVersionsPath,
@@ -55,6 +61,9 @@ import {
zPostAgentByAgentIdComposerValidateBody,
zPostAgentByAgentIdComposerValidatePath,
zPostAgentByAgentIdComposerValidateResponse,
zPostAgentByAgentIdCopyBody,
zPostAgentByAgentIdCopyPath,
zPostAgentByAgentIdCopyResponse,
zPostAgentByAgentIdFeaturesBody,
zPostAgentByAgentIdFeaturesPath,
zPostAgentByAgentIdFeaturesResponse,
@@ -233,6 +242,22 @@ export const composer = {
validate,
}
export const post3 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgentByAgentIdCopy',
path: '/agent/{agent_id}/copy',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostAgentByAgentIdCopyBody, params: zPostAgentByAgentIdCopyPath }))
.output(zPostAgentByAgentIdCopyResponse)
export const copy = {
post: post3,
}
/**
* Time-limited external signed URL for one Agent App drive value
*/
@@ -314,7 +339,7 @@ export const drive = {
/**
* Update an Agent App's presentation features (opener, follow-up, citations, ...)
*/
export const post3 = oc
export const post4 = oc
.route({
description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)',
inputStructure: 'detailed',
@@ -329,13 +354,13 @@ export const post3 = oc
.output(zPostAgentByAgentIdFeaturesResponse)
export const features = {
post: post3,
post: post4,
}
/**
* Create or update Agent App message feedback
*/
export const post4 = oc
export const post5 = oc
.route({
description: 'Create or update Agent App message feedback',
inputStructure: 'detailed',
@@ -350,7 +375,7 @@ export const post4 = oc
.output(zPostAgentByAgentIdFeedbacksResponse)
export const feedbacks = {
post: post4,
post: post5,
}
/**
@@ -373,7 +398,7 @@ export const delete_ = oc
/**
* Commit an uploaded file into the Agent App drive under files/<name>
*/
export const post5 = oc
export const post6 = oc
.route({
description: 'Commit an uploaded file into the Agent App drive under files/<name>',
inputStructure: 'detailed',
@@ -388,13 +413,30 @@ export const post5 = oc
export const files2 = {
delete: delete_,
post: post5,
post: post6,
}
export const get9 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdLogs',
path: '/agent/{agent_id}/logs',
tags: ['console'],
})
.input(
z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }),
)
.output(zGetAgentByAgentIdLogsResponse)
export const logs = {
get: get9,
}
/**
* Get Agent App message details by ID
*/
export const get9 = oc
export const get10 = oc
.route({
description: 'Get Agent App message details by ID',
inputStructure: 'detailed',
@@ -407,7 +449,7 @@ export const get9 = oc
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
export const byMessageId2 = {
get: get9,
get: get10,
}
export const messages = {
@@ -417,7 +459,7 @@ export const messages = {
/**
* List workflow apps that reference this Agent App's bound Agent (read-only)
*/
export const get10 = oc
export const get11 = oc
.route({
description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)',
inputStructure: 'detailed',
@@ -430,13 +472,13 @@ export const get10 = oc
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
export const referencingWorkflows = {
get: get10,
get: get11,
}
/**
* Read a text/binary preview file in an Agent App conversation sandbox
*/
export const get11 = oc
export const get12 = oc
.route({
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
inputStructure: 'detailed',
@@ -454,13 +496,13 @@ export const get11 = oc
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
export const read = {
get: get11,
get: get12,
}
/**
* Upload one Agent App sandbox file as a Dify ToolFile mapping
*/
export const post6 = oc
export const post7 = oc
.route({
description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping',
inputStructure: 'detailed',
@@ -478,13 +520,13 @@ export const post6 = oc
.output(zPostAgentByAgentIdSandboxFilesUploadResponse)
export const upload = {
post: post6,
post: post7,
}
/**
* List a directory in an Agent App conversation sandbox
*/
export const get12 = oc
export const get13 = oc
.route({
description: 'List a directory in an Agent App conversation sandbox',
inputStructure: 'detailed',
@@ -502,7 +544,7 @@ export const get12 = oc
.output(zGetAgentByAgentIdSandboxFilesResponse)
export const files3 = {
get: get12,
get: get13,
read,
upload,
}
@@ -514,7 +556,7 @@ export const sandbox = {
/**
* Validate + standardize a Skill into an Agent App drive
*/
export const post7 = oc
export const post8 = oc
.route({
description: 'Validate + standardize a Skill into an Agent App drive',
inputStructure: 'detailed',
@@ -528,13 +570,13 @@ export const post7 = oc
.output(zPostAgentByAgentIdSkillsStandardizeResponse)
export const standardize = {
post: post7,
post: post8,
}
/**
* Upload + validate a Skill package for an Agent App
*/
export const post8 = oc
export const post9 = oc
.route({
description: 'Upload + validate a Skill package for an Agent App',
inputStructure: 'detailed',
@@ -548,13 +590,13 @@ export const post8 = oc
.output(zPostAgentByAgentIdSkillsUploadResponse)
export const upload2 = {
post: post8,
post: post9,
}
/**
* Infer CLI tool + ENV suggestions from a standardized Agent App skill
*/
export const post9 = oc
export const post10 = oc
.route({
description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill',
inputStructure: 'detailed',
@@ -567,7 +609,7 @@ export const post9 = oc
.output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse)
export const inferTools = {
post: post9,
post: post10,
}
/**
@@ -596,7 +638,31 @@ export const skills = {
bySlug,
}
export const get13 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdStatisticsSummary',
path: '/agent/{agent_id}/statistics/summary',
tags: ['console'],
})
.input(
z.object({
params: zGetAgentByAgentIdStatisticsSummaryPath,
query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(),
}),
)
.output(zGetAgentByAgentIdStatisticsSummaryResponse)
export const summary = {
get: get14,
}
export const statistics = {
summary,
}
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -608,10 +674,10 @@ export const get13 = oc
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
export const byVersionId = {
get: get13,
get: get15,
}
export const get14 = oc
export const get16 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -623,7 +689,7 @@ export const get14 = oc
.output(zGetAgentByAgentIdVersionsResponse)
export const versions = {
get: get14,
get: get16,
byVersionId,
}
@@ -639,7 +705,7 @@ export const delete3 = oc
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
.output(zDeleteAgentByAgentIdResponse)
export const get15 = oc
export const get17 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -663,22 +729,25 @@ export const put2 = oc
export const byAgentId = {
delete: delete3,
get: get15,
get: get17,
put: put2,
chatMessages,
composer,
copy,
drive,
features,
feedbacks,
files: files2,
logs,
messages,
referencingWorkflows,
sandbox,
skills,
statistics,
versions,
}
export const get16 = oc
export const get18 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -689,7 +758,7 @@ export const get16 = oc
.input(z.object({ query: zGetAgentQuery.optional() }))
.output(zGetAgentResponse)
export const post10 = oc
export const post11 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -702,8 +771,8 @@ export const post10 = oc
.output(zPostAgentResponse)
export const agent = {
get: get16,
post: post10,
get: get18,
post: post11,
inviteOptions,
byAgentId,
}
@@ -4,8 +4,8 @@ export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type AppPagination = {
data: Array<AppPartial>
export type AgentAppPagination = {
data: Array<AgentAppPartial>
has_more: boolean
limit: number
page: number
@@ -23,6 +23,7 @@ export type AgentAppCreatePayload = {
export type AppDetailWithSite = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@@ -121,6 +122,14 @@ export type AgentComposerValidateResponse = {
warnings?: Array<ComposerValidationWarningResponse>
}
export type CopyAppPayload = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: IconType | null
name?: string | null
}
export type AgentDriveListResponse = {
items?: Array<AgentDriveItemResponse>
}
@@ -168,6 +177,14 @@ export type AgentDriveFileCommitResponse = {
file: AgentDriveFileResponse
}
export type AgentLogListResponse = {
data: Array<AgentLogItemResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type MessageDetailResponse = {
agent_thoughts?: Array<AgentThought>
annotation?: ConversationAnnotation | null
@@ -241,6 +258,12 @@ export type SkillToolInferenceResult = {
reason?: string | null
}
export type AgentStatisticSummaryEnvelopeResponse = {
charts: AgentStatisticChartsResponse
source: string
summary: AgentStatisticSummaryResponse
}
export type AgentConfigSnapshotListResponse = {
data: Array<AgentConfigSnapshotSummaryResponse>
}
@@ -257,8 +280,9 @@ export type AgentConfigSnapshotDetailResponse = {
version_note?: string | null
}
export type AppPartial = {
export type AgentAppPartial = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null
@@ -277,6 +301,8 @@ export type AppPartial = {
mode: string
model_config?: ModelConfigPartial | null
name: string
published_reference_count?: number
published_references?: Array<AgentAppPublishedReferenceResponse>
role?: string | null
tags?: Array<Tag>
updated_at?: number | null
@@ -345,6 +371,7 @@ export type WorkflowPartial = {
}
export type AgentInviteOptionResponse = {
active_config_is_published?: boolean
active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null
active_config_snapshot_id?: string | null
agent_kind: AgentKind
@@ -527,6 +554,29 @@ export type AgentDriveFileResponse = {
size?: number | null
}
export type AgentLogItemResponse = {
answer: string
answer_tokens: number
conversation_id: string
conversation_name?: string | null
created_at?: number | null
currency: string
error?: string | null
from_account_id?: string | null
from_end_user_id?: string | null
from_source?: string | null
id: string
latency: number
message_id: string
message_tokens: number
query: string
source?: string | null
status: string
total_price: string
total_tokens: number
updated_at?: number | null
}
export type AgentThought = {
chain_id?: string | null
created_at?: number | null
@@ -641,6 +691,30 @@ export type CliToolSuggestion = {
name: string
}
export type AgentStatisticChartsResponse = {
average_response_time?: Array<AgentAverageResponseTimeStatisticResponse>
average_session_interactions?: Array<AgentAverageSessionInteractionStatisticResponse>
daily_conversations?: Array<AgentDailyConversationStatisticResponse>
daily_end_users?: Array<AgentDailyEndUserStatisticResponse>
daily_messages?: Array<AgentDailyMessageStatisticResponse>
token_usage?: Array<AgentTokenUsageStatisticResponse>
tokens_per_second?: Array<AgentTokensPerSecondStatisticResponse>
user_satisfaction_rate?: Array<AgentUserSatisfactionRateStatisticResponse>
}
export type AgentStatisticSummaryResponse = {
average_response_time: number
average_session_interactions: number
currency: string
tokens_per_second: number
total_conversations: number
total_end_users: number
total_messages: number
total_price: string
total_tokens: number
user_satisfaction_rate: number
}
export type AgentConfigRevisionResponse = {
created_at?: number | null
created_by?: string | null
@@ -662,6 +736,14 @@ export type ModelConfigPartial = {
updated_by?: string | null
}
export type AgentAppPublishedReferenceResponse = {
app_icon?: string | null
app_icon_background?: string | null
app_icon_type?: string | null
app_id: string
app_name: string
}
export type LlmMode = 'chat' | 'completion'
export type AgentKind = 'dify_agent'
@@ -757,6 +839,26 @@ export type AgentSoulToolsConfig = {
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem | null
check?: DeclaredOutputCheckConfig | null
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig | null
@@ -930,6 +1032,48 @@ export type EnvSuggestion = {
secret_likely?: boolean
}
export type AgentAverageResponseTimeStatisticResponse = {
date: string
latency: number
}
export type AgentAverageSessionInteractionStatisticResponse = {
date: string
interactions: number
}
export type AgentDailyConversationStatisticResponse = {
conversation_count: number
date: string
}
export type AgentDailyEndUserStatisticResponse = {
date: string
terminal_count: number
}
export type AgentDailyMessageStatisticResponse = {
date: string
message_count: number
}
export type AgentTokenUsageStatisticResponse = {
currency: string
date: string
token_count: number
total_price: string
}
export type AgentTokensPerSecondStatisticResponse = {
date: string
tps: number
}
export type AgentUserSatisfactionRateStatisticResponse = {
date: string
rate: number
}
export type AgentConfigRevisionOperation
= | 'create_version'
| 'save_current_version'
@@ -949,6 +1093,7 @@ export type AgentSecretRefConfig = {
provider_credential_id?: string | null
ref?: string | null
type?: string | null
value?: string | null
variable?: string | null
[key: string]: unknown
}
@@ -1073,6 +1218,26 @@ export type AgentSoulDifyToolConfig = {
}
export type DeclaredArrayItem = {
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
type: DeclaredOutputType
}
@@ -1214,8 +1379,8 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type AppPaginationWritable = {
data: Array<AppPartialWritable>
export type AgentAppPaginationWritable = {
data: Array<AgentAppPartialWritable>
has_more: boolean
limit: number
page: number
@@ -1224,6 +1389,7 @@ export type AppPaginationWritable = {
export type AppDetailWithSiteWritable = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@@ -1251,8 +1417,9 @@ export type AppDetailWithSiteWritable = {
workflow?: WorkflowPartial | null
}
export type AppPartialWritable = {
export type AgentAppPartialWritable = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null
@@ -1270,6 +1437,8 @@ export type AppPartialWritable = {
mode: string
model_config?: ModelConfigPartial | null
name: string
published_reference_count?: number
published_references?: Array<AgentAppPublishedReferenceResponse>
role?: string | null
tags?: Array<Tag>
updated_at?: number | null
@@ -1319,7 +1488,7 @@ export type GetAgentData = {
}
export type GetAgentResponses = {
200: AppPagination
200: AgentAppPagination
}
export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses]
@@ -1542,6 +1711,27 @@ export type PostAgentByAgentIdComposerValidateResponses = {
export type PostAgentByAgentIdComposerValidateResponse
= PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses]
export type PostAgentByAgentIdCopyData = {
body: CopyAppPayload
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/copy'
}
export type PostAgentByAgentIdCopyErrors = {
400: unknown
403: unknown
}
export type PostAgentByAgentIdCopyResponses = {
201: AppDetailWithSite
}
export type PostAgentByAgentIdCopyResponse
= PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses]
export type GetAgentByAgentIdDriveFilesData = {
body?: never
path: {
@@ -1671,6 +1861,30 @@ export type PostAgentByAgentIdFilesResponses = {
export type PostAgentByAgentIdFilesResponse
= PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses]
export type GetAgentByAgentIdLogsData = {
body?: never
path: {
agent_id: string
}
query?: {
end?: string
keyword?: string
limit?: number
page?: number
source?: string
start?: string
status?: string
}
url: '/agent/{agent_id}/logs'
}
export type GetAgentByAgentIdLogsResponses = {
200: AgentLogListResponse
}
export type GetAgentByAgentIdLogsResponse
= GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses]
export type GetAgentByAgentIdMessagesByMessageIdData = {
body?: never
path: {
@@ -1840,6 +2054,26 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = {
export type PostAgentByAgentIdSkillsBySlugInferToolsResponse
= PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses]
export type GetAgentByAgentIdStatisticsSummaryData = {
body?: never
path: {
agent_id: string
}
query?: {
end?: string
source?: string
start?: string
}
url: '/agent/{agent_id}/statistics/summary'
}
export type GetAgentByAgentIdStatisticsSummaryResponses = {
200: AgentStatisticSummaryEnvelopeResponse
}
export type GetAgentByAgentIdStatisticsSummaryResponse
= GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses]
export type GetAgentByAgentIdVersionsData = {
body?: never
path: {
@@ -109,6 +109,17 @@ export const zAgentAppUpdatePayload = z.object({
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* CopyAppPayload
*/
export const zCopyAppPayload = z.object({
description: z.string().max(400).nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zIconType.nullish(),
name: z.string().nullish(),
})
/**
* DeletedTool
*/
@@ -321,6 +332,43 @@ export const zAgentDriveFileCommitResponse = z.object({
file: zAgentDriveFileResponse,
})
/**
* AgentLogItemResponse
*/
export const zAgentLogItemResponse = z.object({
answer: z.string(),
answer_tokens: z.int(),
conversation_id: z.string(),
conversation_name: z.string().nullish(),
created_at: z.int().nullish(),
currency: z.string(),
error: z.string().nullish(),
from_account_id: z.string().nullish(),
from_end_user_id: z.string().nullish(),
from_source: z.string().nullish(),
id: z.string(),
latency: z.number(),
message_id: z.string(),
message_tokens: z.int(),
query: z.string(),
source: z.string().nullish(),
status: z.string(),
total_price: z.string(),
total_tokens: z.int(),
updated_at: z.int().nullish(),
})
/**
* AgentLogListResponse
*/
export const zAgentLogListResponse = z.object({
data: z.array(zAgentLogItemResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AgentThought
*/
@@ -458,6 +506,22 @@ export const zAgentSkillUploadResponse = z.object({
skill: zAgentSkillRefConfig,
})
/**
* AgentStatisticSummaryResponse
*/
export const zAgentStatisticSummaryResponse = z.object({
average_response_time: z.number(),
average_session_interactions: z.number(),
currency: z.string(),
tokens_per_second: z.number(),
total_conversations: z.int(),
total_end_users: z.int(),
total_messages: z.int(),
total_price: z.string(),
total_tokens: z.int(),
user_satisfaction_rate: z.number(),
})
/**
* ModelConfigPartial
*/
@@ -471,10 +535,22 @@ export const zModelConfigPartial = z.object({
})
/**
* AppPartial
* AgentAppPublishedReferenceResponse
*/
export const zAppPartial = z.object({
export const zAgentAppPublishedReferenceResponse = z.object({
app_icon: z.string().nullish(),
app_icon_background: z.string().nullish(),
app_icon_type: z.string().nullish(),
app_id: z.string(),
app_name: z.string(),
})
/**
* AgentAppPartial
*/
export const zAgentAppPartial = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@@ -493,6 +569,8 @@ export const zAppPartial = z.object({
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
name: z.string(),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentAppPublishedReferenceResponse).optional(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
@@ -502,10 +580,10 @@ export const zAppPartial = z.object({
})
/**
* AppPagination
* AgentAppPagination
*/
export const zAppPagination = z.object({
data: z.array(zAppPartial),
export const zAgentAppPagination = z.object({
data: z.array(zAgentAppPartial),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
@@ -534,6 +612,7 @@ export const zModelConfig = z.object({
*/
export const zAppDetailWithSite = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@@ -620,6 +699,7 @@ export const zAgentStatus = z.enum(['active', 'archived'])
* AgentInviteOptionResponse
*/
export const zAgentInviteOptionResponse = z.object({
active_config_is_published: z.boolean().optional().default(false),
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
@@ -882,6 +962,97 @@ export const zSkillToolInferenceResult = z.object({
reason: z.string().nullish(),
})
/**
* AgentAverageResponseTimeStatisticResponse
*/
export const zAgentAverageResponseTimeStatisticResponse = z.object({
date: z.string(),
latency: z.number(),
})
/**
* AgentAverageSessionInteractionStatisticResponse
*/
export const zAgentAverageSessionInteractionStatisticResponse = z.object({
date: z.string(),
interactions: z.number(),
})
/**
* AgentDailyConversationStatisticResponse
*/
export const zAgentDailyConversationStatisticResponse = z.object({
conversation_count: z.int(),
date: z.string(),
})
/**
* AgentDailyEndUserStatisticResponse
*/
export const zAgentDailyEndUserStatisticResponse = z.object({
date: z.string(),
terminal_count: z.int(),
})
/**
* AgentDailyMessageStatisticResponse
*/
export const zAgentDailyMessageStatisticResponse = z.object({
date: z.string(),
message_count: z.int(),
})
/**
* AgentTokenUsageStatisticResponse
*/
export const zAgentTokenUsageStatisticResponse = z.object({
currency: z.string(),
date: z.string(),
token_count: z.int(),
total_price: z.string(),
})
/**
* AgentTokensPerSecondStatisticResponse
*/
export const zAgentTokensPerSecondStatisticResponse = z.object({
date: z.string(),
tps: z.number(),
})
/**
* AgentUserSatisfactionRateStatisticResponse
*/
export const zAgentUserSatisfactionRateStatisticResponse = z.object({
date: z.string(),
rate: z.number(),
})
/**
* AgentStatisticChartsResponse
*/
export const zAgentStatisticChartsResponse = z.object({
average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(),
average_session_interactions: z
.array(zAgentAverageSessionInteractionStatisticResponse)
.optional(),
daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(),
daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(),
daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(),
token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(),
tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(),
user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(),
})
/**
* AgentStatisticSummaryEnvelopeResponse
*/
export const zAgentStatisticSummaryEnvelopeResponse = z.object({
charts: zAgentStatisticChartsResponse,
source: z.string(),
summary: zAgentStatisticSummaryResponse,
})
/**
* AgentConfigRevisionOperation
*
@@ -1078,6 +1249,25 @@ export const zWorkflowNodeJobMetadata = z.object({
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
type: zDeclaredOutputType,
})
@@ -1136,6 +1326,7 @@ export const zAgentSecretRefConfig = z.object({
provider_credential_id: z.string().max(255).nullish(),
ref: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
value: z.string().max(255).nullish(),
variable: z.string().max(255).nullish(),
})
@@ -1515,6 +1706,25 @@ export const zDeclaredOutputFailureStrategy = z.object({
export const zDeclaredOutputConfig = z.object({
array_item: zDeclaredArrayItem.nullish(),
check: zDeclaredOutputCheckConfig.nullish(),
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
failure_strategy: zDeclaredOutputFailureStrategy.optional(),
file: zDeclaredOutputFileConfig.nullish(),
@@ -1731,10 +1941,11 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
})
/**
* AppPartial
* AgentAppPartial
*/
export const zAppPartialWritable = z.object({
export const zAgentAppPartialWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@@ -1752,6 +1963,8 @@ export const zAppPartialWritable = z.object({
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
name: z.string(),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentAppPublishedReferenceResponse).optional(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
@@ -1761,10 +1974,10 @@ export const zAppPartialWritable = z.object({
})
/**
* AppPagination
* AgentAppPagination
*/
export const zAppPaginationWritable = z.object({
data: z.array(zAppPartialWritable),
export const zAgentAppPaginationWritable = z.object({
data: z.array(zAgentAppPartialWritable),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
@@ -1795,6 +2008,7 @@ export const zSiteWritable = z.object({
*/
export const zAppDetailWithSiteWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@@ -1851,7 +2065,7 @@ export const zGetAgentQuery = z.object({
/**
* Agent app list
*/
export const zGetAgentResponse = zAppPagination
export const zGetAgentResponse = zAgentAppPagination
export const zPostAgentBody = zAgentAppCreatePayload
@@ -1977,6 +2191,17 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({
*/
export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse
export const zPostAgentByAgentIdCopyBody = zCopyAppPayload
export const zPostAgentByAgentIdCopyPath = z.object({
agent_id: z.string(),
})
/**
* Agent app copied successfully
*/
export const zPostAgentByAgentIdCopyResponse = zAppDetailWithSite
export const zGetAgentByAgentIdDriveFilesPath = z.object({
agent_id: z.string(),
})
@@ -2062,6 +2287,25 @@ export const zPostAgentByAgentIdFilesPath = z.object({
*/
export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse
export const zGetAgentByAgentIdLogsPath = z.object({
agent_id: z.string(),
})
export const zGetAgentByAgentIdLogsQuery = z.object({
end: z.string().optional(),
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
source: z.string().optional(),
start: z.string().optional(),
status: z.string().optional(),
})
/**
* Agent logs
*/
export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse
export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({
agent_id: z.string(),
message_id: z.string(),
@@ -2158,6 +2402,21 @@ export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({
*/
export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({
agent_id: z.string(),
})
export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({
end: z.string().optional(),
source: z.string().optional(),
start: z.string().optional(),
})
/**
* Agent monitoring summary and chart data
*/
export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse
export const zGetAgentByAgentIdVersionsPath = z.object({
agent_id: z.string(),
})
@@ -5,10 +5,10 @@ export type ClientOptions = {
}
export type AppPagination = {
data: Array<AppPartial>
has_more: boolean
limit: number
has_next: boolean
items: Array<AppPartial>
page: number
per_page: number
total: number
}
@@ -23,6 +23,7 @@ export type CreateAppPayload = {
export type AppDetailWithSite = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@@ -1155,23 +1156,23 @@ export type ApiKeyItem = {
export type AppPartial = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
app_model_config?: ModelConfigPartial | null
author_name?: string | null
bound_agent_id?: string | null
create_user_name?: string | null
created_at?: number | null
created_by?: string | null
description?: string | null
desc_or_prompt?: string | null
has_draft_trigger?: boolean | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
id: string
is_starred?: boolean
max_active_requests?: number | null
mode: string
model_config?: ModelConfigPartial | null
mode_compatible_with_agent: string
name: string
role?: string | null
tags?: Array<Tag>
@@ -1760,6 +1761,26 @@ export type AgentComposerBindingResponse = {
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem | null
check?: DeclaredOutputCheckConfig | null
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig | null
@@ -2103,6 +2124,26 @@ export type AgentSoulToolsConfig = {
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
export type DeclaredArrayItem = {
children?: Array<{
array_item?: {
children?: Array<{
[key: string]: unknown
}>
description?: string | null
type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
[key: string]: unknown
}
children?: Array<{
[key: string]: unknown
}>
description?: string | null
file?: {
[key: string]: unknown
}
name: string
required?: boolean
type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
}>
description?: string | null
type: DeclaredOutputType
}
@@ -2305,6 +2346,7 @@ export type AgentSecretRefConfig = {
provider_credential_id?: string | null
ref?: string | null
type?: string | null
value?: string | null
variable?: string | null
[key: string]: unknown
}
@@ -2533,16 +2575,9 @@ export type AgentModerationIoConfig = {
export type ValueSourceType = 'constant' | 'variable'
export type AppPaginationWritable = {
data: Array<AppPartialWritable>
has_more: boolean
limit: number
page: number
total: number
}
export type AppDetailWithSiteWritable = {
access_mode?: string | null
active_config_is_published?: boolean
api_base_url?: string | null
app_id?: string | null
bound_agent_id?: string | null
@@ -2593,33 +2628,6 @@ export type WorkflowCommentDetailWritable = {
updated_at?: number | null
}
export type AppPartialWritable = {
access_mode?: string | null
app_id?: string | null
author_name?: string | null
bound_agent_id?: string | null
create_user_name?: string | null
created_at?: number | null
created_by?: string | null
description?: string | null
has_draft_trigger?: boolean | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
is_starred?: boolean
max_active_requests?: number | null
mode: string
model_config?: ModelConfigPartial | null
name: string
role?: string | null
tags?: Array<Tag>
updated_at?: number | null
updated_by?: string | null
use_icon_as_answer_icon?: boolean | null
workflow?: WorkflowPartial | null
}
export type SiteWritable = {
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
@@ -1946,23 +1946,23 @@ export const zModelConfigPartial = z.object({
*/
export const zAppPartial = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
app_model_config: zModelConfigPartial.nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
create_user_name: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
description: z.string().nullish(),
desc_or_prompt: z.string().nullish(),
has_draft_trigger: z.boolean().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
id: z.string(),
is_starred: z.boolean().optional().default(false),
max_active_requests: z.int().nullish(),
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
mode_compatible_with_agent: z.string(),
name: z.string(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
@@ -1976,10 +1976,10 @@ export const zAppPartial = z.object({
* AppPagination
*/
export const zAppPagination = z.object({
data: z.array(zAppPartial),
has_more: z.boolean(),
limit: z.int(),
has_next: z.boolean(),
items: z.array(zAppPartial),
page: z.int(),
per_page: z.int(),
total: z.int(),
})
@@ -2005,6 +2005,7 @@ export const zModelConfig = z.object({
*/
export const zAppDetailWithSite = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
@@ -2475,6 +2476,25 @@ export const zAgentComposerBindingResponse = z.object({
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
type: zDeclaredOutputType,
})
@@ -2908,6 +2928,7 @@ export const zAgentSecretRefConfig = z.object({
provider_credential_id: z.string().max(255).nullish(),
ref: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
value: z.string().max(255).nullish(),
variable: z.string().max(255).nullish(),
})
@@ -3079,6 +3100,25 @@ export const zDeclaredOutputCheckConfig = z.object({
export const zDeclaredOutputConfig = z.object({
array_item: zDeclaredArrayItem.nullish(),
check: zDeclaredOutputCheckConfig.nullish(),
children: z
.array(
z.object({
array_item: z
.object({
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(),
})
.optional(),
children: z.array(z.record(z.string(), z.unknown())).optional(),
description: z.string().nullish(),
file: z.record(z.string(), z.unknown()).optional(),
name: z.string(),
required: z.boolean().optional(),
type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']),
}),
)
.optional(),
description: z.string().nullish(),
failure_strategy: zDeclaredOutputFailureStrategy.optional(),
file: zDeclaredOutputFileConfig.nullish(),
@@ -3432,47 +3472,6 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
*/
export const zGeneratedAppResponseWritable = zJsonValue
/**
* AppPartial
*/
export const zAppPartialWritable = z.object({
access_mode: z.string().nullish(),
app_id: z.string().nullish(),
author_name: z.string().nullish(),
bound_agent_id: z.string().nullish(),
create_user_name: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
description: z.string().nullish(),
has_draft_trigger: z.boolean().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
is_starred: z.boolean().optional().default(false),
max_active_requests: z.int().nullish(),
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
name: z.string(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
workflow: zWorkflowPartial.nullish(),
})
/**
* AppPagination
*/
export const zAppPaginationWritable = z.object({
data: z.array(zAppPartialWritable),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* Site
*/
@@ -3497,6 +3496,7 @@ export const zSiteWritable = z.object({
*/
export const zAppDetailWithSiteWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
api_base_url: z.string().nullish(),
app_id: z.string().nullish(),
bound_agent_id: z.string().nullish(),
+15
View File
@@ -166,8 +166,23 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
- `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates.
- `pnpm -C packages/dify-ui type-check``tsgo --noEmit` for this package only.
### Test Boundary
Use Storybook tests for behavior that belongs to the documented component example:
visible state changes, user interaction, keyboard paths, overlay open/close flows,
and accessibility-facing semantics. Keep regular Vitest unit tests for lower-level
wrapper contracts such as class variants, Base UI passthrough props, hidden input
serialization, data attribute hooks, store behavior, and edge cases that do not
need a full story.
Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`.
If a story is temporarily marked `todo`, keep the exception local to that story
and do not treat an interaction `play` test as a replacement for fixing the
underlying accessibility issue.
### Disabling Animations In Tests
Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior.
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
AlertDialog,
AlertDialogActions,
@@ -55,6 +56,21 @@ export const Default: Story = {
</AlertDialogContent>
</AlertDialog>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Delete project' }))
const dialog = body.getByRole('alertdialog', { name: 'Delete project?' })
await waitFor(async () => {
await expect(dialog).toBeVisible()
})
await userEvent.click(body.getByRole('button', { name: 'Cancel' }))
await waitFor(async () => {
await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument()
})
},
}
export const NonDestructive: Story = {
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, fn } from 'storybook/test'
import { Button } from '.'
@@ -90,8 +91,21 @@ export const Loading: Story = {
args: {
variant: 'primary',
loading: true,
onClick: fn(),
children: 'Loading Button',
},
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole('button', { name: 'Loading Button' })
await expect(button).toHaveAttribute('aria-disabled', 'true')
await expect(button).toHaveAttribute('aria-busy', 'true')
button.focus()
await expect(button).toHaveFocus()
await userEvent.click(button)
await expect(args.onClick).not.toHaveBeenCalled()
},
parameters: {
docs: {
description: {
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import { useVirtualizer } from '@tanstack/react-virtual'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
Combobox,
ComboboxChip,
@@ -768,6 +769,15 @@ const MultipleChipsDemo = () => {
export const MultipleChips: Story = {
render: () => <MultipleChipsDemo />,
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByText('Maya Chen')).toBeVisible()
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: 'Remove Maya Chen' }))
await expect(canvas.queryByText('Maya Chen')).not.toBeInTheDocument()
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
},
}
export const VirtualizedLongList: Story = {
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
Dialog,
DialogCloseButton,
@@ -66,6 +67,22 @@ export const Default: Story = {
</DialogContent>
</Dialog>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }))
const dialog = body.getByRole('dialog', { name: 'Invite collaborators' })
await waitFor(async () => {
await expect(dialog).toBeVisible()
})
await expect(body.getByRole('textbox', { name: 'Email address' })).toBeVisible()
await userEvent.click(body.getByRole('button', { name: 'Close' }))
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument()
})
},
}
export const WithoutCloseButton: Story = {
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { FileTreeIconType } from '.'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
FileTreeBadge,
FileTreeFile,
@@ -330,6 +331,19 @@ function VisualStates() {
export const Default: Story = {
render: () => <ComposedFileTree />,
play: async ({ canvas, userEvent }) => {
const srcFolder = canvas.getByRole('button', { name: 'src' })
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
await userEvent.click(srcFolder)
await expect(srcFolder).toHaveAttribute('aria-expanded', 'false')
await expect(canvas.queryByRole('button', { name: 'components' })).not.toBeInTheDocument()
await userEvent.click(srcFolder)
await expect(srcFolder).toHaveAttribute('aria-expanded', 'true')
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
},
}
export const DataDriven: Story = {
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
Pagination,
PaginationSkeleton,
@@ -77,6 +78,15 @@ type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => <PaginationDemo />,
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: 'Next page' }))
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: '50' }))
await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true')
},
parameters: {
a11y: {
test: 'todo',
+26 -2
View File
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
Select,
SelectContent,
@@ -19,6 +20,8 @@ const triggerWidth = 'w-64'
const cityItems = [
{ label: 'Seattle', value: 'seattle' },
{ label: 'New York', value: 'new-york' },
{ label: 'Tokyo', value: 'tokyo' },
{ label: 'Paris', value: 'paris' },
]
const meta = {
@@ -41,11 +44,11 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle">
<Select items={cityItems} defaultValue="seattle">
<SelectTrigger aria-label="City">
<SelectValue placeholder="Select a city" />
</SelectTrigger>
<SelectContent>
<SelectContent listProps={{ 'aria-label': 'City options' }}>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
@@ -66,6 +69,27 @@ export const Default: Story = {
</Select>
</div>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const trigger = canvas.getByRole('combobox', { name: 'City' })
const body = within(canvasElement.ownerDocument.body)
await expect(trigger).toHaveTextContent('Seattle')
trigger.focus()
await userEvent.keyboard('{ArrowDown}')
await waitFor(async () => {
await expect(body.getByRole('option', { name: 'Tokyo' })).toBeVisible()
})
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
await expect(trigger).toHaveTextContent('Tokyo')
await userEvent.keyboard('{Escape}')
await waitFor(async () => {
await expect(body.queryByRole('listbox', { name: 'City options' })).not.toBeInTheDocument()
})
},
}
export const WithVisibleLabel: Story = {
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect } from 'storybook/test'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
@@ -77,6 +78,17 @@ export const Default: Story = {
checked: false,
disabled: false,
},
play: async ({ canvas, userEvent }) => {
const switchControl = canvas.getByRole('switch', { name: 'Enable auto retry' })
await expect(switchControl).toHaveAttribute('aria-checked', 'false')
await expect(canvas.getByText('Failures require manual retry.')).toBeVisible()
await userEvent.click(switchControl)
await expect(switchControl).toHaveAttribute('aria-checked', 'true')
await expect(canvas.getByText('Failures will retry automatically.')).toBeVisible()
},
}
export const DefaultOn: Story = {
@@ -13,7 +13,6 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import s from './style.module.css'
type IAppDetailLayoutProps = {
children: React.ReactNode
@@ -116,9 +115,19 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
)
}
const isWorkflowPage = pathname.endsWith('/workflow')
return (
<div className={cn(s.app, 'relative ml-1 flex', 'overflow-hidden')}>
<div className="grow overflow-hidden bg-components-panel-bg">
<div className={cn(
'relative flex h-0 grow overflow-hidden',
!isWorkflowPage && 'pt-1 pr-1 pb-1',
)}
>
<div className={cn(
'grow overflow-hidden bg-components-panel-bg',
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</div>
@@ -10,6 +10,7 @@ import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/component
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import { useDocLink } from '@/context/i18n'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@@ -34,6 +35,7 @@ type IChartViewProps = {
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
@@ -46,10 +48,26 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
return null
return (
<div>
<div className="mb-4">
<div className="mb-2 system-xl-semibold text-text-primary">{t('appMenus.overview', { ns: 'common' })}</div>
<div className="flex items-center justify-between">
<div className="flex h-full min-h-0 flex-col">
<div className="h-[106px] shrink-0">
<div className="px-6 pt-3">
<div className="flex h-6 items-center">
<h1 className="title-2xl-semi-bold text-text-primary">{t('appMenus.overview', { ns: 'common' })}</h1>
</div>
<div className="mt-0.5 flex h-4 min-w-0 items-start gap-0.5 system-xs-regular text-text-tertiary">
<p className="min-w-0 truncate">{t('monitoring.description', { ns: 'appLog' })}</p>
<a
href={docLink('/use-dify/monitor/analysis')}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center text-text-accent hover:underline"
>
<span>{t('operation.learnMore', { ns: 'common' })}</span>
<span className="i-ri-external-link-line size-3" aria-hidden="true" />
</a>
</div>
</div>
<div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6">
{IS_CLOUD_EDITION
? (
<TimeRangePicker
@@ -69,47 +87,35 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
{headerRight}
</div>
</div>
{!isWorkflow && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<ConversationsChart period={period} id={appId} />
<EndUsersChart period={period} id={appId} />
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2 pb-6">
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
{!isWorkflow && (
<>
<ConversationsChart period={period} id={appId} />
<EndUsersChart period={period} id={appId} />
{isChatApp
? (
<AvgSessionInteractions period={period} id={appId} />
)
: (
<AvgResponseTime period={period} id={appId} />
)}
<TokenPerSecond period={period} id={appId} />
<UserSatisfactionRate period={period} id={appId} />
<CostChart period={period} id={appId} />
{isChatApp && <MessagesChart period={period} id={appId} />}
</>
)}
{isWorkflow && (
<>
<WorkflowMessagesChart period={period} id={appId} />
<WorkflowDailyTerminalsChart period={period} id={appId} />
<WorkflowCostChart period={period} id={appId} />
<AvgUserInteractions period={period} id={appId} />
</>
)}
</div>
)}
{!isWorkflow && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
{isChatApp
? (
<AvgSessionInteractions period={period} id={appId} />
)
: (
<AvgResponseTime period={period} id={appId} />
)}
<TokenPerSecond period={period} id={appId} />
</div>
)}
{!isWorkflow && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<UserSatisfactionRate period={period} id={appId} />
<CostChart period={period} id={appId} />
</div>
)}
{!isWorkflow && isChatApp && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<MessagesChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<WorkflowMessagesChart period={period} id={appId} />
<WorkflowDailyTerminalsChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className="mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2">
<WorkflowCostChart period={period} id={appId} />
<AvgUserInteractions period={period} id={appId} />
</div>
)}
</div>
</div>
)
}
@@ -15,12 +15,14 @@ const Overview = async (props: IDevelopProps) => {
} = params
return (
<div className="h-full overflow-y-auto bg-chatbot-bg px-4 py-6 sm:px-12">
<div className="flex h-full min-h-0 flex-col">
<ApikeyInfoPanel />
<ChartView
appId={appId}
headerRight={<TracingPanel />}
/>
<div className="min-h-0 flex-1">
<ChartView
appId={appId}
headerRight={<TracingPanel />}
/>
</div>
</div>
)
}
@@ -247,7 +247,7 @@ const Panel: FC = () => {
>
<div
className={cn(
'flex cursor-pointer items-center rounded-xl border-t border-l-[0.5px] border-effects-highlight bg-background-default-dodge p-2 shadow-xs select-none hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
'flex cursor-pointer items-center rounded-xl border-[0.5px] border-components-panel-border bg-background-default-dodge p-2 shadow-xs select-none hover:bg-background-default-lighter',
)}
>
<TracingIcon size="md" />
@@ -286,7 +286,7 @@ const Panel: FC = () => {
>
<div
className={cn(
'flex cursor-pointer items-center rounded-xl border-t border-l-[0.5px] border-effects-highlight bg-background-default-dodge p-2 shadow-xs select-none hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
'flex cursor-pointer items-center rounded-xl border-[0.5px] border-components-panel-border bg-background-default-dodge p-2 shadow-xs select-none hover:bg-background-default-lighter',
)}
>
<div className="mr-1 ml-4 flex items-center">
@@ -1,6 +0,0 @@
.app {
flex-grow: 1;
height: 0;
border-radius: 16px 16px 0px 0px;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.05), 0px 0px 2px -1px rgba(0, 0, 0, 0.03);
}
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from '@testing-library/react'
import { useRouter } from '@/next/navigation'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
import DatasetDetailLayout from '../layout-main'
@@ -31,11 +31,13 @@ vi.mock('@/hooks/use-document-title', () => ({
}))
const mockUseRouter = vi.mocked(useRouter)
const mockUsePathname = vi.mocked(usePathname)
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
describe('DatasetDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents')
mockUseRouter.mockReturnValue({
back: vi.fn(),
forward: vi.fn(),
@@ -118,5 +120,82 @@ describe('DatasetDetailLayout', () => {
expect(screen.getByText('Pipeline content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should apply the dataset surface outside pipeline pages', () => {
// Arrange
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'rag_pipeline',
is_published: true,
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Documents content</div>
</DatasetDetailLayout>,
)
// Assert
expect(screen.getByText('Documents content').parentElement).toHaveClass('rounded-lg')
})
it('should keep pipeline pages unframed', () => {
// Arrange
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'rag_pipeline',
is_published: false,
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Pipeline content</div>
</DatasetDetailLayout>,
)
// Assert
expect(screen.getByText('Pipeline content').parentElement).not.toHaveClass('rounded-lg')
})
it('should keep create-from-pipeline pages unframed', () => {
// Arrange
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create-from-pipeline')
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'rag_pipeline',
is_published: false,
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Create from pipeline content</div>
</DatasetDetailLayout>,
)
// Assert
expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass('rounded-lg')
})
})
})
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import DatasetDetailContext from '@/context/dataset-detail'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter } from '@/next/navigation'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
type IAppDetailLayoutProps = {
@@ -35,6 +35,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
} = props
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const shouldRedirect = shouldRedirectToDatasetList(error)
@@ -52,11 +53,13 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
if (shouldRedirect)
return <Loading type="app" />
const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline')
return (
<div
className={cn(
'flex grow overflow-hidden',
'rounded-t-2xl',
'relative flex h-0 grow overflow-hidden',
!isPipelinePage && 'pt-1 pr-1 pb-1',
)}
>
<DatasetDetailContext.Provider value={{
@@ -65,7 +68,13 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
mutateDatasetRes,
}}
>
<div className="grow overflow-hidden bg-background-default-subtle">{children}</div>
<div className={cn(
'grow overflow-hidden bg-components-panel-bg',
!isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</DatasetDetailContext.Provider>
</div>
)
@@ -1,4 +1,4 @@
import { redirect } from 'next/navigation'
import { redirect } from '@/next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
@@ -1,5 +1,6 @@
'use client'
import type { ComponentProps } from 'react'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -31,6 +32,15 @@ type AppDetailNavItem = {
selectedIcon: NavIcon
}
const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annotations>) => (
<Annotations
{...props}
className={cn(className, 'size-4')}
/>
)
AnnotationNavIcon.displayName = 'Annotations'
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
@@ -98,8 +108,8 @@ const AppDetailSection = ({
? [{
name: t('appMenus.annotations', { ns: 'common' }),
href: `/app/${appId}/annotations`,
icon: Annotations,
selectedIcon: Annotations,
icon: AnnotationNavIcon,
selectedIcon: AnnotationNavIcon,
}]
: [])]
: []
@@ -95,11 +95,11 @@ const Chart: React.FC<IChartProps> = ({
const tokenSummary = getTokenSummary(statistics)
return (
<div className={`flex w-full flex-col rounded-xl bg-components-chart-bg px-6 py-4 shadow-xs ${className ?? ''}`}>
<div className="mb-3">
<div className={`flex h-[316px] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg ${className ?? ''}`}>
<div className="flex h-11 shrink-0 items-center px-6 pt-6 pb-1">
<Basic name={title} type={timePeriod} hoverTip={explanation} />
</div>
<div className="mb-4 flex-1">
<div className="flex h-8 shrink-0 items-start px-6 py-1">
<Basic
isExtraInLine={CHART_TYPE_CONFIG[chartType].showTokens}
name={summaryValue}
@@ -123,7 +123,7 @@ const Chart: React.FC<IChartProps> = ({
textStyle={{ main: `text-3xl! font-normal! ${summaryValue === '0' || summaryValue === '0 ms' ? 'text-text-quaternary!' : ''}` }}
/>
</div>
<ReactECharts option={options} style={{ height: 160 }} />
<ReactECharts option={options} style={{ height: 240, width: '100%' }} />
</div>
)
}
@@ -16,6 +16,7 @@ import Loading from '@/app/components/base/loading'
import { APP_PAGE_LIMIT } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useWorkflowLogs } from '@/service/use-log'
import PageTitle from '../log-annotation/page-title'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
import List from './list'
@@ -67,8 +68,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
return (
<div className="flex h-full flex-col">
<h1 className="system-xl-semibold text-text-primary">{t('workflowTitle', { ns: 'appLog' })}</h1>
<p className="system-sm-regular text-text-tertiary">{t('workflowSubtitle', { ns: 'appLog' })}</p>
<PageTitle
title={t('workflowTitle', { ns: 'appLog' })}
description={t('workflowSubtitle', { ns: 'appLog' })}
/>
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
{/* workflow log */}
@@ -417,15 +417,17 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render link to snippets before the create button', () => {
it('should render sort filter before search and the snippets link', () => {
renderList()
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
expect(snippetsLink).toHaveAttribute('href', '/snippets')
expect(sortButton.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
@@ -62,6 +62,7 @@ export function AppListHeaderFilters({
showLeadingIcon={false}
/>
<CreatorsFilter value={creatorIDs} onChange={onCreatorIDsChange} />
<AppSortFilter value={sortBy} onChange={onSortByChange} />
<SearchInput
className="w-50"
value={keywords}
@@ -70,7 +71,6 @@ export function AppListHeaderFilters({
/>
</div>
<div className="flex items-center gap-2">
<AppSortFilter value={sortBy} onChange={onSortByChange} />
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
@@ -484,6 +484,21 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
it('hides the main menu on snippet detail routes while keeping account settings available', () => {
mockPathname = '/snippets/snippet-1/orchestrate'
renderMainNav()
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
})
it('replaces global navigation with app detail navigation on app routes', () => {
mockPathname = '/app/app-1/overview'
@@ -118,6 +118,7 @@ function WorkspaceCardTrigger({
title={name}
className={cn(
'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset',
showCloudBilling ? 'rounded-t-xl' : 'rounded-xl',
open && 'bg-linear-to-b from-background-section-burn to-background-section',
)}
>
+39 -24
View File
@@ -60,6 +60,12 @@ const isDatasetDetailPathname = (pathname: string) => {
return true
}
const isSnippetDetailPathname = (pathname: string) => {
const [section, snippetId] = pathname.split('/').filter(Boolean)
return section === 'snippets' && !!snippetId
}
const MainNav = ({
className,
}: MainNavProps) => {
@@ -70,6 +76,7 @@ const MainNav = ({
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const showAppDetailNavigation = !isCurrentWorkspaceDatasetOperator && pathname.startsWith('/app/')
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation
const { hasAppDetail, appSidebarExpand, setAppDetail, setAppSidebarExpand } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
@@ -87,7 +94,9 @@ const MainNav = ({
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
? false
: !showDetailNavigation || detailNavigationVisibleExpanded
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
@@ -234,7 +243,9 @@ const MainNav = ({
? detailNavigationExpanded
? 'w-[248px] bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
'bg-background-body',
className,
)}
@@ -267,32 +278,36 @@ const MainNav = ({
onToggle={handleToggleDetailNavigation}
/>
)
: (
<>
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className="p-2">
<WorkspaceCard />
</div>
</>
)}
: showSnippetDetailBottomNavigation
? null
: (
<>
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className="p-2">
<WorkspaceCard />
</div>
</>
)}
{showDetailNavigation
? showAppDetailNavigation
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
: <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: (
<>
<nav className="flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && detailNavigationVisibleExpanded && (
: showSnippetDetailBottomNavigation
? null
: (
<>
<nav className="flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
@@ -1,5 +1,6 @@
import type { VersionHistory } from '@/types/workflow'
import { screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
@@ -10,6 +11,15 @@ const mockInvalidAllLastRun = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
let mockPlanType = Plan.professional
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },
enableBilling: mockEnableBilling,
}),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
@@ -75,6 +85,8 @@ const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory
describe('HeaderInRestoring', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPlanType = Plan.professional
mockEnableBilling = true
})
it('should disable restore when the flow id is not ready yet', () => {
@@ -125,4 +137,26 @@ describe('HeaderInRestoring', () => {
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
it('should show plan upgrade modal instead of restoring when sandbox users click restore', () => {
mockPlanType = Plan.sandbox
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
},
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
},
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument()
expect(mockRestoreWorkflow).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})
@@ -4,9 +4,13 @@ import { toast } from '@langgenius/dify-ui/toast'
import { RiHistoryLine } from '@remixicon/react'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow'
import { FlowType } from '@/types/common'
@@ -32,6 +36,8 @@ const HeaderInRestoring = ({
}: HeaderInRestoringProps) => {
const { t } = useTranslation()
const { theme } = useTheme()
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
const { plan, enableBilling } = useProviderContext()
const workflowStore = useWorkflowStore()
const userProfile = useAppContextSelector(s => s.userProfile)
const configsMap = useHooksStore(s => s.configsMap)
@@ -49,6 +55,7 @@ const HeaderInRestoring = ({
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox
const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow
const handleCancelRestore = useCallback(() => {
@@ -116,6 +123,11 @@ const HeaderInRestoring = ({
if (!canRestore || !currentVersion)
return
if (!canUseWorkflowVersionAction) {
setIsRestorePlanUpgradeModalOpen(true)
return
}
setShowWorkflowVersionHistoryPanel(false)
await emitRestoreIntent()
@@ -138,7 +150,7 @@ const HeaderInRestoring = ({
resetWorkflowVersionHistory()
onRestoreSettled?.()
}
}, [canRestore, currentVersion, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled])
}, [canRestore, currentVersion, canUseWorkflowVersionAction, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled])
return (
<>
@@ -170,6 +182,14 @@ const HeaderInRestoring = ({
</div>
</Button>
</div>
{isRestorePlanUpgradeModalOpen && (
<PlanUpgradeModal
show
onClose={() => setIsRestorePlanUpgradeModalOpen(false)}
title={t('upgrade.workflowRestore.title', { ns: 'billing' })!}
description={t('upgrade.workflowRestore.description', { ns: 'billing' })!}
/>
)}
</>
)
}
@@ -1,16 +1,23 @@
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { Plan } from '@/app/components/billing/type'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockHandleExportDSL = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockEmitRestoreIntent = vi.fn()
const mockEmitRestoreComplete = vi.fn()
const mockEmitWorkflowUpdate = vi.fn()
let mockPlanType = Plan.professional
let mockEnableBilling = true
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
@@ -56,6 +63,13 @@ vi.mock('@/context/app-context', () => ({
useSelector: () => ({ id: 'test-user-id' }),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },
enableBilling: mockEnableBilling,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidAllLastRun: () => vi.fn(),
@@ -88,7 +102,7 @@ vi.mock('@/service/use-workflow', () => ({
}))
vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useDSL: () => ({ handleExportDSL: mockHandleExportDSL }),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
@@ -103,6 +117,14 @@ vi.mock('../../../hooks-store', () => ({
}),
}))
vi.mock('../../../collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitRestoreIntent: mockEmitRestoreIntent,
emitRestoreComplete: mockEmitRestoreComplete,
emitWorkflowUpdate: mockEmitWorkflowUpdate,
},
}))
vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
const state: MockVersionStoreState = {
@@ -149,19 +171,27 @@ vi.mock('../version-history-item', () => ({
default: (props: MockVersionHistoryItemProps) => {
const MockVersionHistoryItem = () => {
const { item, onClick, handleClickActionMenuItem } = props
const didSelectDraftRef = useRef(false)
useEffect(() => {
if (item.version === WorkflowVersion.Draft)
if (item.version === WorkflowVersion.Draft && !didSelectDraftRef.current) {
didSelectDraftRef.current = true
onClick(item)
}
}, [item, onClick])
return (
<div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
{item.version !== WorkflowVersion.Draft && (
<button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
<>
<button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
<button onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.exportDSL)}>
{`export-${item.id}`}
</button>
</>
)}
</div>
)
@@ -174,7 +204,10 @@ vi.mock('../version-history-item', () => ({
describe('VersionHistoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRestoreWorkflow.mockResolvedValue(undefined)
mockCurrentVersion = null
mockPlanType = Plan.professional
mockEnableBilling = true
})
describe('Version Click Behavior', () => {
@@ -221,6 +254,9 @@ describe('VersionHistoryPanel', () => {
/>,
)
await waitFor(() => {
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
})
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
@@ -237,9 +273,47 @@ describe('VersionHistoryPanel', () => {
})
})
it('should show plan upgrade modal instead of restore confirmation for sandbox users', async () => {
const { VersionHistoryPanel } = await import('../index')
mockPlanType = Plan.sandbox
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument()
expect(screen.queryByText('confirm restore')).not.toBeInTheDocument()
expect(mockRestoreWorkflow).not.toHaveBeenCalled()
})
it('should show plan upgrade modal instead of exporting DSL for sandbox users', async () => {
const { VersionHistoryPanel } = await import('../index')
mockPlanType = Plan.sandbox
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('export-published-version-id'))
expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument()
expect(mockHandleExportDSL).not.toHaveBeenCalled()
})
it('should keep restore mode backup state when restore request fails', async () => {
const { VersionHistoryPanel } = await import('../index')
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
mockCurrentVersion = createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
@@ -253,6 +327,7 @@ describe('VersionHistoryPanel', () => {
)
vi.clearAllMocks()
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
@@ -1,10 +1,35 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
import { VersionHistoryContextMenuOptions } from '../../../../types'
import ActionMenu from '../index'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
let mockPlanType = Plan.professional
let mockEnableBilling = true
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },
enableBilling: mockEnableBilling,
}),
}))
describe('ActionMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPlanType = Plan.professional
mockEnableBilling = true
})
it('toggles the trigger and forwards menu clicks', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
@@ -34,4 +59,27 @@ describe('ActionMenu', () => {
VersionHistoryContextMenuOptions.delete,
)
})
it('shows upgrade buttons beside restore and export for sandbox users', async () => {
const user = userEvent.setup()
const handleClickActionMenuItem = vi.fn()
mockPlanType = Plan.sandbox
renderWorkflowComponent(
<ActionMenu
isNamedVersion
isShowDelete
open
setOpen={vi.fn()}
handleClickActionMenuItem={handleClickActionMenuItem}
/>,
)
const upgradeButtons = screen.getAllByRole('button', { name: 'billing.upgradeBtn.encourageShort' })
expect(upgradeButtons).toHaveLength(2)
await user.click(upgradeButtons[0]!)
expect(handleClickActionMenuItem).not.toHaveBeenCalled()
})
})
@@ -3,11 +3,13 @@ import type { VersionHistoryContextMenuOptions } from '../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
type ActionMenuItemProps = {
item: {
key: VersionHistoryContextMenuOptions
name: string
showUpgrade?: boolean
}
onClick: (operation: VersionHistoryContextMenuOptions) => void
isDestructive?: boolean
@@ -22,21 +24,38 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({
<DropdownMenuItem
variant={isDestructive ? 'destructive' : 'default'}
className={cn(
'justify-between px-2 py-1.5',
'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap',
isDestructive && 'data-highlighted:bg-state-destructive-hover',
)}
onClick={(event) => {
event.stopPropagation()
const target = event.target
if (target instanceof Element && target.closest('[data-upgrade-action]'))
return
onClick(item.key)
}}
>
<div className={cn(
'flex-1 system-md-regular text-text-primary',
'flex-1 system-md-regular whitespace-nowrap text-text-primary',
isDestructive && 'text-inherit',
)}
>
{item.name}
</div>
{item.showUpgrade && (
<div
data-upgrade-action
className="shrink-0"
>
<UpgradeBtn
size="custom"
isShort
loc="workflow-version-history-menu"
className="h-5! rounded-md! px-1!"
/>
</div>
)}
</DropdownMenuItem>
)
}
@@ -41,7 +41,7 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[184px] shadow-shadow-shadow-5"
popupClassName="w-max min-w-[184px] max-w-[calc(100vw-24px)] shadow-shadow-shadow-5"
>
{
options.map(option => (
@@ -1,7 +1,9 @@
import type { ActionMenuProps } from './index'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { useStore } from '@/app/components/workflow/store'
import { useProviderContext } from '@/context/provider-context'
import { VersionHistoryContextMenuOptions } from '../../../types'
const useActionMenu = (props: ActionMenuProps) => {
@@ -10,6 +12,8 @@ const useActionMenu = (props: ActionMenuProps) => {
} = props
const { t } = useTranslation()
const pipelineId = useStore(s => s.pipelineId)
const { plan, enableBilling } = useProviderContext()
const shouldShowUpgrade = enableBilling && plan.type === Plan.sandbox
const deleteOperation = {
key: VersionHistoryContextMenuOptions.delete,
@@ -21,6 +25,7 @@ const useActionMenu = (props: ActionMenuProps) => {
{
key: VersionHistoryContextMenuOptions.restore,
name: t('common.restore', { ns: 'workflow' }),
...(shouldShowUpgrade ? { showUpgrade: true } : {}),
},
isNamedVersion
? {
@@ -36,6 +41,7 @@ const useActionMenu = (props: ActionMenuProps) => {
? [{
key: VersionHistoryContextMenuOptions.exportDSL,
name: t('export', { ns: 'app' }),
...(shouldShowUpgrade ? { showUpgrade: true } : {}),
}]
: []),
{
@@ -43,7 +49,7 @@ const useActionMenu = (props: ActionMenuProps) => {
name: t('versionHistory.copyId', { ns: 'workflow' }),
},
]
}, [isNamedVersion, pipelineId, t])
}, [isNamedVersion, pipelineId, shouldShowUpgrade, t])
return {
deleteOperation,
@@ -8,7 +8,10 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal'
import Divider from '@/app/components/base/divider'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store'
@@ -43,8 +46,11 @@ export const VersionHistoryPanel = ({
const [isOnlyShowNamedVersions, setIsOnlyShowNamedVersions] = useState(false)
const [operatedItem, setOperatedItem] = useState<VersionHistory>()
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [editModalOpen, setEditModalOpen] = useState(false)
const { plan, enableBilling } = useProviderContext()
const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox
const workflowStore = useWorkflowStore()
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
@@ -111,6 +117,10 @@ export const VersionHistoryPanel = ({
setOperatedItem(item)
switch (operation) {
case VersionHistoryContextMenuOptions.restore:
if (!canUseWorkflowVersionAction) {
setIsRestorePlanUpgradeModalOpen(true)
break
}
setRestoreConfirmOpen(true)
break
case VersionHistoryContextMenuOptions.edit:
@@ -124,10 +134,14 @@ export const VersionHistoryPanel = ({
toast.success(t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }))
break
case VersionHistoryContextMenuOptions.exportDSL:
if (!canUseWorkflowVersionAction) {
setIsRestorePlanUpgradeModalOpen(true)
break
}
handleExportDSL?.(false, item.id)
break
}
}, [t, handleExportDSL])
}, [canUseWorkflowVersionAction, t, handleExportDSL])
const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => {
switch (operation) {
@@ -330,6 +344,14 @@ export const VersionHistoryPanel = ({
onRestore={handleRestore}
/>
)}
{isRestorePlanUpgradeModalOpen && (
<PlanUpgradeModal
show
onClose={() => setIsRestorePlanUpgradeModalOpen(false)}
title={t('upgrade.workflowRestore.title', { ns: 'billing' })!}
description={t('upgrade.workflowRestore.description', { ns: 'billing' })!}
/>
)}
{deleteConfirmOpen && (
<DeleteConfirmModal
isOpen={deleteConfirmOpen}
+1
View File
@@ -42,6 +42,7 @@
"filter.period.today": "اليوم",
"filter.period.yearToDate": "السنة حتى الآن",
"filter.sortBy": "رتب حسب:",
"monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.",
"promptLog": "سجل المطالبة",
"runDetail.fileListDetail": "تفاصيل",
"runDetail.fileListLabel": "تفاصيل الملف",
+2
View File
@@ -164,6 +164,8 @@
"upgrade.uploadMultipleFiles.title": "قم بالترقية لفتح ميزة تحميل المستندات دفعة واحدة",
"upgrade.uploadMultiplePages.description": "لقد وصلت إلى حد التحميل — يمكن اختيار ورفع مستند واحد فقط في كل مرة على الخطة الحالية الخاصة بك.",
"upgrade.uploadMultiplePages.title": "قم بالترقية لتحميل عدة مستندات دفعة واحدة",
"upgrade.workflowRestore.description": "استعادة إصدارات سير العمل غير متاحة في خطتك الحالية.",
"upgrade.workflowRestore.title": "قم بالترقية لاستعادة إصدارات سير العمل",
"upgradeBtn.encourage": "الترقية الآن",
"upgradeBtn.encourageShort": "ترقية",
"upgradeBtn.plain": "عرض الخطة",
+1
View File
@@ -42,6 +42,7 @@
"filter.period.today": "Heute",
"filter.period.yearToDate": "Jahr bis heute",
"filter.sortBy": "Sortieren nach:",
"monitoring.description": "Das Monitoring zeichnet den Betriebsstatus der Anwendung auf, einschließlich Leistung, Nutzeraktivität und Kosten.",
"promptLog": "Prompt-Protokoll",
"runDetail.fileListDetail": "Detail",
"runDetail.fileListLabel": "Details zur Datei",

Some files were not shown because too many files have changed in this diff Show More