Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784dd3a555 | ||
|
|
8782da42c8 | ||
|
|
e6a91bfcde | ||
|
|
912c0fa8d1 | ||
|
|
872b5a081f | ||
|
|
758bea1a91 | ||
|
|
3b0f6aef8e | ||
|
|
f203ab7f1d | ||
|
|
e970cbde0f | ||
|
|
f992ede836 | ||
|
|
6ab5cf109b | ||
|
|
8ca8b3d59a | ||
|
|
3f81ec1212 | ||
|
|
e189ceb397 | ||
|
|
bacc48d16e | ||
|
|
7cb4a30040 | ||
|
|
56dce93524 | ||
|
|
813a1677b2 | ||
|
|
1427b0b098 | ||
|
|
2893adf5e4 | ||
|
|
eb2aaf2ac1 |
@@ -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,
|
||||
|
||||
@@ -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__])
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+320
@@ -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
|
||||
|
||||
+12
-15
@@ -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):
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
+33
-33
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+80
-1
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
+48
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+21
-2
@@ -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 => (
|
||||
|
||||
+7
-1
@@ -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}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"filter.period.today": "اليوم",
|
||||
"filter.period.yearToDate": "السنة حتى الآن",
|
||||
"filter.sortBy": "رتب حسب:",
|
||||
"monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.",
|
||||
"promptLog": "سجل المطالبة",
|
||||
"runDetail.fileListDetail": "تفاصيل",
|
||||
"runDetail.fileListLabel": "تفاصيل الملف",
|
||||
|
||||
@@ -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": "عرض الخطة",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user