Compare commits

..

6 Commits

36 changed files with 884 additions and 2327 deletions

View File

@ -1,12 +1,11 @@
from uuid import UUID
from flask import abort, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
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,
@ -28,22 +27,14 @@ 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
@ -72,49 +63,11 @@ class AgentAppUpdatePayload(UpdateAppPayload):
role: str | None = Field(default=None, description="Agent role", max_length=255)
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,
AgentInviteOptionsQuery,
AgentLogsQuery,
AgentStatisticsQuery,
AgentIdPath,
AppListQuery,
UpdateAppPayload,
@ -127,10 +80,8 @@ register_response_schema_models(
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
)
@ -185,19 +136,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
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))
return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.route("/agent")
@ -328,65 +267,6 @@ 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__])

View File

@ -1,10 +1,8 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import Field, field_validator
from pydantic import Field
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.agent import (
AgentConfigRevisionOperation,
AgentIconType,
@ -107,114 +105,6 @@ 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

View File

@ -638,26 +638,6 @@ 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
@ -810,22 +790,6 @@ 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
@ -11354,20 +11318,6 @@ 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.
@ -11608,27 +11558,6 @@ 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 |
@ -11868,41 +11797,6 @@ 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 |
@ -11930,18 +11824,6 @@ 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 |
@ -12315,50 +12197,6 @@ 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.
@ -12401,22 +12239,6 @@ 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 |
@ -12431,13 +12253,6 @@ 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 |

View File

@ -35,7 +35,6 @@ Example:
"""
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, TypedDict
@ -66,21 +65,6 @@ 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.
@ -302,36 +286,6 @@ 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,
@ -416,19 +370,6 @@ 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,
@ -476,19 +417,6 @@ 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,

View File

@ -44,11 +44,7 @@ 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,
WorkflowRunCleanupRef,
)
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.types import (
AverageInteractionStats,
@ -424,71 +420,6 @@ 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,
@ -599,56 +530,6 @@ 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,
@ -830,72 +711,6 @@ 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,

View File

@ -1,300 +0,0 @@
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,
}

View File

@ -1,10 +1,3 @@
"""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
@ -18,11 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
RunsWithRelatedCountsDict,
WorkflowRunCleanupRef,
)
from models.workflow import WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.factory import DifyAPIRepositoryFactory
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.billing_service import BillingService, SubscriptionPlan
@ -196,13 +186,6 @@ _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,
@ -271,28 +254,26 @@ class WorkflowRunCleanup:
batch_start = time.monotonic()
fetch_start = time.monotonic()
candidate_last_seen = last_seen
candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=candidate_last_seen,
last_seen=last_seen,
batch_size=self.batch_size,
)
if not candidate_refs:
if not run_rows:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
batch_index += 1
candidate_high_water = self._cursor_from_ref(candidate_refs[-1])
last_seen = candidate_high_water
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms",
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
batch_index,
len(candidate_refs),
len(run_rows),
int((time.monotonic() - fetch_start) * 1000),
)
tenant_ids = {ref.tenant_id for ref in candidate_refs}
tenant_ids = {row.tenant_id for row in run_rows}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
@ -304,28 +285,10 @@ class WorkflowRunCleanup:
int((time.monotonic() - filter_start) * 1000),
)
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),
)
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_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:
if not free_runs:
skipped_message = (
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
)
@ -336,7 +299,7 @@ class WorkflowRunCleanup:
)
)
self._metrics.record_batch(
batch_rows=len(candidate_refs),
batch_rows=len(run_rows),
targeted_runs=0,
skipped_runs=paid_or_skipped,
deleted_runs=0,
@ -346,13 +309,13 @@ class WorkflowRunCleanup:
)
continue
total_runs_targeted += len(target_run_ids)
total_runs_targeted += len(free_runs)
if self.dry_run:
count_start = time.monotonic()
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,
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
count_trigger_logs=self._count_trigger_logs,
)
logger.info(
@ -362,10 +325,10 @@ class WorkflowRunCleanup:
)
if related_totals is not None:
self._accumulate_related_counts(related_totals, batch_counts)
sample_ids = ", ".join(target_run_ids[:5])
sample_ids = ", ".join(run.id for run in free_runs[:5])
click.echo(
click.style(
f"[batch #{batch_index}] would delete {len(target_run_ids)} runs "
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
fg="yellow",
)
@ -376,8 +339,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
skipped_runs=paid_or_skipped,
deleted_runs=0,
related_counts={
@ -391,14 +354,14 @@ class WorkflowRunCleanup:
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related_by_ids(
target_run_ids,
delete_node_executions=self._delete_node_executions_by_run_ids,
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
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", candidate_high_water[0])
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
raise
total_runs_deleted += counts["runs"]
@ -419,8 +382,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
skipped_runs=paid_or_skipped,
deleted_runs=counts["runs"],
related_counts={
@ -476,7 +439,7 @@ class WorkflowRunCleanup:
)
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
tenant_id_list = sorted(set(tenant_ids))
tenant_id_list = list(tenant_ids)
if not dify_config.BILLING_ENABLED:
return set(tenant_id_list)
@ -590,17 +553,15 @@ class WorkflowRunCleanup:
totals["pauses"] += batch.get("pauses", 0)
totals["pause_reasons"] += batch.get("pause_reasons", 0)
@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]:
def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
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_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
)

View File

@ -1,320 +0,0 @@
"""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, _scope: _TestScope, 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, scope, 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, scope, 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,
}

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from unittest.mock import patch
from uuid import uuid4
import pytest
@ -258,8 +259,9 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert len(matching_logs) == 1
@patch("services.end_user_service.logger")
def test_get_existing_end_user_matching_type(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
):
"""Test retrieving existing end user with matching type."""
# Arrange
@ -277,19 +279,17 @@ class TestEndUserServiceGetOrCreateEndUserByType:
)
# Act - Request with same type
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,
)
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
# 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] == []
mock_logger.info.assert_not_called()
def test_create_anonymous_user_with_default_session(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory

View File

@ -1,6 +1,5 @@
import logging
import uuid
from unittest.mock import call, patch
from unittest.mock import ANY, call, patch
import pytest
from sqlalchemy import delete, func, select
@ -147,7 +146,10 @@ 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")
def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog):
@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
):
"""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)
@ -161,15 +163,14 @@ class TestDeleteDraftVariablesBatch:
mock_offload_cleanup.return_value = len(file_id_by_index)
with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"):
result = delete_draft_variables_batch(app.id, 50)
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()}
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
assert len(info_records) == 2
assert mock_logger.info.call_count == 2
mock_logger.info.assert_any_call(ANY)
class TestDeleteDraftVariableOffloadData:
@ -203,7 +204,10 @@ class TestDeleteDraftVariableOffloadData:
assert remaining_upload_files_count == 0
@patch("extensions.ext_storage.storage")
def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog):
@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
):
"""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)
@ -213,12 +217,11 @@ class TestDeleteDraftVariableOffloadData:
mock_storage.delete.side_effect = [Exception("Storage error"), None]
with caplog.at_level(logging.ERROR):
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
assert result == 1
assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text
mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0])
remaining_var_files_count = db_session_with_containers.scalar(
select(func.count())

View File

@ -23,10 +23,8 @@ from controllers.console.agent.roster import (
AgentAppApi,
AgentAppListApi,
AgentInviteOptionsApi,
AgentLogsApi,
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
AgentStatisticsSummaryApi,
)
from controllers.console.app import completion as completion_controller
from controllers.console.app import message as message_controller
@ -150,8 +148,6 @@ 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
@ -375,108 +371,6 @@ 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:

View File

@ -2,7 +2,6 @@
Unit tests for Service API File Preview endpoint
"""
import logging
import uuid
from unittest.mock import Mock, patch
@ -349,7 +348,8 @@ class TestFilePreviewApi:
assert "Storage error" in str(exc_info.value)
def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog):
@patch("controllers.service_api.app.file_preview.logger")
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
"""Test that unexpected errors are logged properly"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
@ -359,18 +359,14 @@ class TestFilePreviewApi:
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
# Execute and assert exception
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)
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 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"
# 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"},
)

View File

@ -1,4 +1,3 @@
import logging
import queue
import time
from concurrent.futures import Future, ThreadPoolExecutor
@ -512,8 +511,10 @@ def test_receive_loop_http_error_unknown_id(streams):
@pytest.mark.timeout(10)
def test_receive_loop_validation_error_notification(streams, caplog):
with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"):
def test_receive_loop_validation_error_notification(streams):
from core.mcp.session.base_session import logger
with patch.object(logger, "warning") as mock_warning:
read_stream, write_stream = streams
session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification])
@ -522,7 +523,7 @@ def test_receive_loop_validation_error_notification(streams, caplog):
read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload)))
time.sleep(1.0)
assert "Failed to validate notification" in caplog.text
assert mock_warning.called
@pytest.mark.timeout(5)
@ -570,16 +571,16 @@ def test_session_exit_timeout(streams):
@pytest.mark.timeout(10)
def test_receive_loop_fatal_exception(streams, caplog):
def test_receive_loop_fatal_exception(streams):
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 caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"):
with patch("core.mcp.session.base_session.logger") as mock_logger:
with pytest.raises(RuntimeError, match="Fatal loop error"):
with session:
pass
assert "Error in message processing loop" in caplog.text
mock_logger.exception.assert_called_with("Error in message processing loop")
@pytest.mark.timeout(5)

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -89,10 +88,11 @@ 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, caplog
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock
) -> None:
"""Test that API key overrides conflicting authorization header and logs warning."""
mock_config = SimpleNamespace(
@ -105,8 +105,7 @@ def test_api_key_overrides_conflicting_header(
ENTERPRISE_OTLP_API_KEY="test-key",
)
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
EnterpriseExporter(mock_config)
EnterpriseExporter(mock_config)
# Verify Bearer header takes precedence
assert mock_span_exporter.call_args is not None
@ -117,8 +116,11 @@ def test_api_key_overrides_conflicting_header(
assert ("authorization", "Basic old") not in headers
# Verify warning was logged
assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text
assert "authorization" in caplog.text
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
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
@ -533,33 +535,33 @@ def test_export_span_cross_workflow_parent_context() -> None:
assert kwargs["context"] is not None
def test_export_span_logs_exception_on_error(caplog) -> None:
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> 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")
with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"):
exporter.export_span(name="bad.span", attributes={}) # must not raise
exporter.export_span(name="bad.span", attributes={}) # must not raise
assert "Failed to export span" in caplog.text
assert "bad.span" in caplog.text
mock_logger.exception.assert_called_once()
assert "bad.span" in mock_logger.exception.call_args[0][1]
def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None:
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> 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"
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,
)
exporter.export_span(
name="link.span",
attributes={},
correlation_id="not-a-valid-uuid",
parent_span_id_source=parent_uid,
)
assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text
mock_logger.warning.assert_called()
# ---------------------------------------------------------------------------

View File

@ -1,123 +0,0 @@
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,
}

View File

@ -7,16 +7,15 @@ 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_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),
)
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
@pytest.fixture
@ -342,28 +341,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_cleanup_refs_batch_by_time_range.return_value = []
mock_repo.get_runs_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_by_ids.assert_not_called()
mock_repo.delete_runs_with_related.assert_not_called()
def test_all_paid_skips_delete(self, mock_repo):
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
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_by_ids.assert_not_called()
mock_repo.delete_runs_with_related.assert_not_called()
def test_runs_deleted_successfully(self, mock_repo):
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 = {
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.delete_runs_with_related.return_value = {
"runs": 1,
"node_executions": 0,
"offloads": 0,
@ -377,12 +376,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_by_ids.assert_called_once()
mock_repo.delete_runs_with_related.assert_called_once()
def test_delete_exception_reraises(self, mock_repo):
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")
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")
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
@ -390,7 +389,7 @@ class TestRunDeleteMode:
c.run()
def test_summary_with_window_start(self, mock_repo):
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
mock_repo.get_runs_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
@ -422,10 +421,9 @@ class TestRunDryRunMode:
)
def test_dry_run_no_delete_called(self, mock_repo):
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,
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.count_runs_with_related.return_value = {
"node_executions": 2,
"offloads": 0,
"app_logs": 0,
@ -437,11 +435,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_by_ids.assert_not_called()
mock_repo.count_runs_with_related_by_ids.assert_called_once()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.count_runs_with_related.assert_called_once()
def test_dry_run_summary_with_window_start(self, mock_repo):
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
mock_repo.get_runs_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
@ -456,14 +454,14 @@ class TestRunDryRunMode:
c.run()
def test_dry_run_all_paid_skips_count(self, mock_repo):
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
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_by_ids.assert_not_called()
mock_repo.count_runs_with_related.assert_not_called()
# ---------------------------------------------------------------------------
@ -494,7 +492,7 @@ class TestTriggerLogMethods:
# ---------------------------------------------------------------------------
# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids
# _count_node_executions / _delete_node_executions
# ---------------------------------------------------------------------------
@ -502,23 +500,25 @@ 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_by_run_ids(session, ["r1"])
result = cleanup._count_node_executions(session, runs)
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_by_run_ids(session, ["r1"])
result = cleanup._delete_node_executions(session, runs)
assert result == (5, 1)

View File

@ -3,27 +3,38 @@ 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
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 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
class FakeRepo:
def __init__(
self,
batches: list[list[WorkflowRunCleanupRef]],
batches: list[list[FakeRun]],
delete_result: dict[str, int] | None = None,
count_result: dict[str, int] | None = None,
) -> None:
self.batches = batches
self.candidate_call_idx = 0
self.last_candidate_batch: list[WorkflowRunCleanupRef] = []
self.cleanup_ref_calls: list[dict[str, object]] = []
self.call_idx = 0
self.deleted: list[list[str]] = []
self.counted: list[list[str]] = []
self.delete_result = delete_result or {
@ -45,7 +56,7 @@ class FakeRepo:
"pause_reasons": 0,
}
def get_cleanup_refs_batch_by_time_range(
def get_runs_batch_by_time_range(
self,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
@ -54,50 +65,27 @@ class FakeRepo:
run_types=None,
tenant_ids=None,
workflow_ids=None,
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):
) -> list[FakeRun]:
if self.call_idx >= len(self.batches):
return []
batch = self.batches[self.candidate_call_idx]
self.candidate_call_idx += 1
self.last_candidate_batch = batch
batch = self.batches[self.call_idx]
self.call_idx += 1
return batch
def delete_runs_with_related_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
self.deleted.append(list(run_ids))
self.deleted.append([run.id for run in runs])
result = self.delete_result.copy()
result["runs"] = len(run_ids)
result["runs"] = len(runs)
return result
def count_runs_with_related_by_ids(
self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None
def count_runs_with_related(
self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None
) -> dict[str, int]:
self.counted.append(list(run_ids))
self.counted.append([run.id for run in runs])
result = self.count_result.copy()
result["runs"] = len(run_ids)
result["runs"] = len(runs)
return result
@ -230,8 +218,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
repo = FakeRepo(
batches=[
[
make_ref("run-free", "t_free", cutoff),
make_ref("run-paid", "t_paid", cutoff),
FakeRun("run-free", "t_free", cutoff),
FakeRun("run-paid", "t_paid", cutoff),
]
]
)
@ -252,43 +240,11 @@ 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=[[make_ref("run-paid", "t_paid", cutoff)]])
repo = FakeRepo(batches=[[FakeRun("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)
@ -301,53 +257,6 @@ 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:
@ -359,7 +268,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=[[make_ref("run-free", "t_free", cutoff)]],
batches=[[FakeRun("run-free", "t_free", cutoff)]],
delete_result={
"runs": 0,
"node_executions": 2,
@ -391,13 +300,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_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
raise RuntimeError("delete failed")
cutoff = datetime.datetime.now()
repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]])
repo = FailingRepo(batches=[[FakeRun("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)
@ -414,7 +323,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=[[make_ref("run-free", "t_free", cutoff)]],
batches=[[FakeRun("run-free", "t_free", cutoff)]],
count_result={
"runs": 0,
"node_executions": 2,

View File

@ -50,7 +50,8 @@ class TestDeleteDraftVariableOffloadData:
assert result == 0
mock_conn.execute.assert_not_called()
def test_delete_draft_variable_offload_data_database_failure(self, caplog):
@patch("tasks.remove_app_and_related_data_task.logging")
def test_delete_draft_variable_offload_data_database_failure(self, mock_logging):
"""Test handling of database operation failures."""
mock_conn = MagicMock()
file_ids = ["file-1"]
@ -59,14 +60,13 @@ class TestDeleteDraftVariableOffloadData:
mock_conn.execute.side_effect = Exception("Database error")
# Execute function - should not raise, but log error
with caplog.at_level(logging.ERROR):
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
result = _delete_draft_variable_offload_data(mock_conn, file_ids)
# Should return 0 when error occurs
assert result == 0
# Verify error was logged
assert "Error deleting draft variable offload data:" in caplog.text
mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:")
class TestDeleteWorkflowArchiveLogs:

View File

@ -29,9 +29,6 @@ import {
zGetAgentByAgentIdDriveFilesPreviewResponse,
zGetAgentByAgentIdDriveFilesQuery,
zGetAgentByAgentIdDriveFilesResponse,
zGetAgentByAgentIdLogsPath,
zGetAgentByAgentIdLogsQuery,
zGetAgentByAgentIdLogsResponse,
zGetAgentByAgentIdMessagesByMessageIdPath,
zGetAgentByAgentIdMessagesByMessageIdResponse,
zGetAgentByAgentIdPath,
@ -44,9 +41,6 @@ import {
zGetAgentByAgentIdSandboxFilesReadQuery,
zGetAgentByAgentIdSandboxFilesReadResponse,
zGetAgentByAgentIdSandboxFilesResponse,
zGetAgentByAgentIdStatisticsSummaryPath,
zGetAgentByAgentIdStatisticsSummaryQuery,
zGetAgentByAgentIdStatisticsSummaryResponse,
zGetAgentByAgentIdVersionsByVersionIdPath,
zGetAgentByAgentIdVersionsByVersionIdResponse,
zGetAgentByAgentIdVersionsPath,
@ -397,27 +391,10 @@ export const files2 = {
post: post5,
}
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 get10 = oc
export const get9 = oc
.route({
description: 'Get Agent App message details by ID',
inputStructure: 'detailed',
@ -430,7 +407,7 @@ export const get10 = oc
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
export const byMessageId2 = {
get: get10,
get: get9,
}
export const messages = {
@ -440,7 +417,7 @@ export const messages = {
/**
* List workflow apps that reference this Agent App's bound Agent (read-only)
*/
export const get11 = oc
export const get10 = oc
.route({
description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)',
inputStructure: 'detailed',
@ -453,13 +430,13 @@ export const get11 = oc
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
export const referencingWorkflows = {
get: get11,
get: get10,
}
/**
* Read a text/binary preview file in an Agent App conversation sandbox
*/
export const get12 = oc
export const get11 = oc
.route({
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -477,7 +454,7 @@ export const get12 = oc
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
export const read = {
get: get12,
get: get11,
}
/**
@ -507,7 +484,7 @@ export const upload = {
/**
* List a directory in an Agent App conversation sandbox
*/
export const get13 = oc
export const get12 = oc
.route({
description: 'List a directory in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -525,7 +502,7 @@ export const get13 = oc
.output(zGetAgentByAgentIdSandboxFilesResponse)
export const files3 = {
get: get13,
get: get12,
read,
upload,
}
@ -619,31 +596,7 @@ export const skills = {
bySlug,
}
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
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -655,10 +608,10 @@ export const get15 = oc
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
export const byVersionId = {
get: get15,
get: get13,
}
export const get16 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -670,7 +623,7 @@ export const get16 = oc
.output(zGetAgentByAgentIdVersionsResponse)
export const versions = {
get: get16,
get: get14,
byVersionId,
}
@ -686,7 +639,7 @@ export const delete3 = oc
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
.output(zDeleteAgentByAgentIdResponse)
export const get17 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -710,7 +663,7 @@ export const put2 = oc
export const byAgentId = {
delete: delete3,
get: get17,
get: get15,
put: put2,
chatMessages,
composer,
@ -718,16 +671,14 @@ export const byAgentId = {
features,
feedbacks,
files: files2,
logs,
messages,
referencingWorkflows,
sandbox,
skills,
statistics,
versions,
}
export const get18 = oc
export const get16 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -751,7 +702,7 @@ export const post10 = oc
.output(zPostAgentResponse)
export const agent = {
get: get18,
get: get16,
post: post10,
inviteOptions,
byAgentId,

View File

@ -169,14 +169,6 @@ 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
@ -250,12 +242,6 @@ export type SkillToolInferenceResult = {
reason?: string | null
}
export type AgentStatisticSummaryEnvelopeResponse = {
charts: AgentStatisticChartsResponse
source: string
summary: AgentStatisticSummaryResponse
}
export type AgentConfigSnapshotListResponse = {
data: Array<AgentConfigSnapshotSummaryResponse>
}
@ -544,29 +530,6 @@ 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
@ -681,30 +644,6 @@ 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
@ -1014,48 +953,6 @@ 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'
@ -1820,30 +1717,6 @@ 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: {
@ -2013,26 +1886,6 @@ 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: {

View File

@ -321,43 +321,6 @@ 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
*/
@ -495,22 +458,6 @@ 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
*/
@ -938,97 +885,6 @@ 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
*
@ -2250,25 +2106,6 @@ 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(),
@ -2365,21 +2202,6 @@ 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(),
})

View File

@ -17,7 +17,7 @@ vi.mock('@/next/navigation', () => ({
// Mock useDocLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
}))
// Mock external context providers (these are external dependencies)
@ -155,7 +155,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
renderComponent()
const docLink = screen.getByText('dataset.connectHelper.helper4')
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base')
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/knowledge/connect-external-knowledge-base')
expect(docLink)!.toHaveAttribute('target', '_blank')
expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer')
})

View File

@ -22,6 +22,7 @@ vi.mock('react-i18next', () => ({
vi.mock('@/context/i18n', () => ({
defaultDocBaseUrl: 'https://docs.dify.ai',
getDocHomePath: () => '/home',
}))
vi.mock('@/i18n-config/language', () => ({
@ -45,7 +46,7 @@ describe('docsCommand', () => {
docsCommand.execute?.()
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('https://docs.dify.ai'),
'https://docs.dify.ai/en/home',
'_blank',
'noopener,noreferrer',
)
@ -85,7 +86,11 @@ describe('docsCommand', () => {
const handlers = vi.mocked(registerCommands).mock.calls[0]![0]
await handlers['navigation.doc']!()
expect(openSpy).toHaveBeenCalledWith('https://docs.dify.ai/en', '_blank', 'noopener,noreferrer')
expect(openSpy).toHaveBeenCalledWith(
'https://docs.dify.ai/en/home',
'_blank',
'noopener,noreferrer',
)
openSpy.mockRestore()
})

View File

@ -2,13 +2,20 @@ import type { SlashCommandHandler } from './types'
import { RiBookOpenLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { defaultDocBaseUrl } from '@/context/i18n'
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
import { getDocLanguage } from '@/i18n-config/language'
import { registerCommands, unregisterCommands } from './command-bus'
// Documentation command dependency types - no external dependencies needed
type DocDeps = Record<string, never>
const getDocsHomeUrl = () => {
const i18n = getI18n()
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
return `${defaultDocBaseUrl}/${docLanguage}${getDocHomePath()}`
}
/**
* Documentation command - Opens help documentation
*/
@ -19,11 +26,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
// Direct execution function
execute: () => {
const i18n = getI18n()
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
@ -43,14 +46,9 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
},
register(_deps: DocDeps) {
const i18n = getI18n()
registerCommands({
'navigation.doc': async (_args) => {
// Get the current language from i18n
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
},
})
},

View File

@ -11,8 +11,8 @@ describe('Empty State', () => {
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
const link = screen.getByText('common.apiBasedExtension.link')
expect(link).toBeInTheDocument()
// The real useDocLink includes the language prefix (defaulting to /en in tests)
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
// The real useDocLink includes language and product prefixes in tests.
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
})
})
})

View File

@ -527,7 +527,7 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('common.toolsPage.toolPlugin')).toHaveLength(2)
expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument()
expect(screen.getByText('common.toolsPage.description').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools')
})
it('aligns model provider headers to the unified content frame', () => {
@ -549,7 +549,7 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('MCP')).toHaveLength(2)
expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/build/mcp')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/build/mcp')
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
@ -558,7 +558,7 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2)
expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#custom-tool')
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
@ -579,7 +579,7 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2)
expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument()
expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
@ -601,7 +601,7 @@ describe('IntegrationsPage', () => {
expect(screen.getAllByText('workflow.common.workflowAsTool')).toHaveLength(2)
expect(screen.getByText('common.workflowAsToolPage.description')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})

View File

@ -17,7 +17,7 @@ vi.mock('@/context/app-context', () => ({
// Mock useLocale and useDocLink
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : path}`,
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
}))
// Mock getLanguage
@ -139,7 +139,7 @@ describe('CustomCreateCard', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const docLink = screen.getByText('tools.swaggerAPIAsToolTip').closest('a')
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#custom-tool')
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/cloud/use-dify/workspace/tools#custom-tool')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
})

View File

@ -84,7 +84,7 @@ describe('Empty', () => {
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.goToStudio/i })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('target', '_blank')
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/tools#workflow-tool')
})
})

View File

@ -47,4 +47,16 @@ describe('useAvailableNodesMetaData', () => {
title: 'workflow.blocks.start',
})
})
it('should use explicit docs pages and skip nodes without generated docs pages', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.End]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/output')
expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input')
})
})

View File

@ -12,8 +12,20 @@ import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-sche
import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import { docPathProductAvailability } from '@/types/doc-paths'
import { useIsChatMode } from './use-is-chat-mode'
const getNodeHelpLinkPath = (helpLinkUri?: string): DocPathWithoutLang | undefined => {
if (!helpLinkUri)
return undefined
const helpLinkPath = `/use-dify/nodes/${helpLinkUri}`
if (!docPathProductAvailability[helpLinkPath])
return undefined
return helpLinkPath as DocPathWithoutLang
}
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
@ -49,14 +61,14 @@ export const useAvailableNodesMetaData = () => {
const { metaData } = node
const title = t(`blocks.${metaData.type}`, { ns: 'workflow' })
const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' })
const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri)
return {
...node,
metaData: {
...metaData,
title,
description,
helpLinkUri: docLink(helpLinkPath),
helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined,
},
defaultValue: {
...node.defaultValue,

View File

@ -317,7 +317,7 @@ describe('NodeSelector', () => {
expect(await screen.findByText('workflow.tabs.unconfiguredStartDisabledTip')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'workflow.tabs.startDisabledTipLearnMore' })).toHaveAttribute(
'href',
'https://docs.dify.ai/en/use-dify/nodes/trigger/overview',
'https://docs.dify.ai/en/self-host/use-dify/nodes/trigger/overview',
)
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})

View File

@ -6,6 +6,7 @@ import { genNodeMetaData } from '@/app/components/workflow/utils'
const metaData = genNodeMetaData({
sort: 2.1,
type: BlockEnum.End,
helpLinkUri: 'output',
isRequired: false,
})
const nodeDefault: NodeDefault<EndNodeType> = {

View File

@ -5,6 +5,10 @@ import { useTranslation } from '#i18n'
import { getDocLanguage } from '@/i18n-config/language'
import { defaultDocBaseUrl, useDocLink } from './i18n'
const mockConfig = vi.hoisted(() => ({
IS_CLOUD_EDITION: true,
}))
// Mock dependencies
vi.mock('#i18n', () => ({
useTranslation: vi.fn(() => ({
@ -12,6 +16,12 @@ vi.mock('#i18n', () => ({
})),
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
}))
vi.mock('@/i18n-config/language', () => ({
getDocLanguage: vi.fn((locale: string) => {
const map: Record<string, string> = {
@ -28,6 +38,7 @@ vi.mock('@/i18n-config/language', () => ({
describe('useDocLink', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useTranslation).mockReturnValue({
i18n: { language: 'en-US' },
} as ReturnType<typeof useTranslation>)
@ -45,28 +56,28 @@ describe('useDocLink', () => {
it('should use default base URL when no baseUrl provided', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current()
expect(url).toBe(`${defaultDocBaseUrl}/en`)
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
})
it('should use custom base URL when provided', () => {
const customBaseUrl = 'https://custom.docs.com'
const { result } = renderHook(() => useDocLink(customBaseUrl))
const url = result.current()
expect(url).toBe(`${customBaseUrl}/en`)
expect(url).toBe(`${customBaseUrl}/en/home`)
})
it('should remove trailing slash from base URL', () => {
const baseUrlWithSlash = 'https://docs.dify.ai/'
const { result } = renderHook(() => useDocLink(baseUrlWithSlash))
const url = result.current('/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
})
it('should handle base URL without trailing slash', () => {
const baseUrlWithoutSlash = 'https://docs.dify.ai'
const { result } = renderHook(() => useDocLink(baseUrlWithoutSlash))
const url = result.current('/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/cloud/use-dify/getting-started/introduction')
})
})
@ -74,19 +85,31 @@ describe('useDocLink', () => {
it('should handle path parameter', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction')
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
})
it('should handle empty path', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current()
expect(url).toBe(`${defaultDocBaseUrl}/en`)
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
})
it('should handle undefined path', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current(undefined)
expect(url).toBe(`${defaultDocBaseUrl}/en`)
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
})
it('should keep common docs path without product prefix', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/learn/key-concepts' as DocPathWithoutLang)
expect(url).toBe(`${defaultDocBaseUrl}/en/learn/key-concepts`)
})
it('should keep explicit product docs path without adding another product prefix', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/cloud/use-dify/build/mcp' as DocPathWithoutLang)
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
})
})
@ -99,12 +122,12 @@ describe('useDocLink', () => {
const pathMap: DocPathMap = {
'zh-Hans': '/use-dify/getting-started/introduction',
'en-US': '/use-dify/getting-started/quick-start',
'en-US': '/use-dify/build/mcp',
}
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
const url = result.current('/use-dify/build/mcp', pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
})
it('should use default path when locale not in pathMap', () => {
@ -115,18 +138,76 @@ describe('useDocLink', () => {
const pathMap: DocPathMap = {
'zh-Hans': '/use-dify/getting-started/introduction',
'en-US': '/use-dify/getting-started/quick-start',
'en-US': '/use-dify/build/mcp',
}
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`)
const url = result.current('/use-dify/build/mcp', pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`)
})
it('should handle undefined pathMap', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction', undefined)
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
})
})
describe('Product prefix handling', () => {
it('should add cloud product prefix for product docs available in both editions', () => {
mockConfig.IS_CLOUD_EDITION = true
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/build/mcp')
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
})
it('should add self-host product prefix for product docs available in both editions outside cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/build/mcp')
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/build/mcp`)
})
it('should use the existing cloud docs path for cloud-only product docs outside cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/workspace/subscription-management#dify-for-education')
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`)
})
it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = true
const { result } = renderHook(() => useDocLink())
const url = result.current('/deploy/overview')
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
})
it('should not add a product prefix for unknown productless paths', () => {
mockConfig.IS_CLOUD_EDITION = false
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/unknown-page' as DocPathWithoutLang)
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/unknown-page`)
})
it('should open shared docs home when no path is provided outside cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
const { result } = renderHook(() => useDocLink())
const url = result.current()
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
})
it('should keep self-host deploy paths without adding use-dify product prefix', () => {
mockConfig.IS_CLOUD_EDITION = true
const { result } = renderHook(() => useDocLink())
const url = result.current('/self-host/deploy/overview' as DocPathWithoutLang)
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/deploy/overview`)
})
})
@ -232,7 +313,7 @@ describe('useDocLink', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction')
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
})
})
@ -240,15 +321,15 @@ describe('useDocLink', () => {
it('should handle path with anchor', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction#overview' as DocPathWithoutLang)
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction#overview`)
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction#overview`)
})
it('should handle multiple calls with same hook instance', () => {
const { result } = renderHook(() => useDocLink())
const url1 = result.current('/use-dify/getting-started/introduction')
const url2 = result.current('/use-dify/getting-started/quick-start')
expect(url1).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
expect(url2).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/quick-start`)
const url2 = result.current('/use-dify/build/mcp')
expect(url1).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
expect(url2).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
})
})
})

View File

@ -1,9 +1,10 @@
import type { Locale } from '@/i18n-config/language'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import type { DocPathWithoutLang, DocsProduct } from '@/types/doc-paths'
import { useCallback } from 'react'
import { useTranslation } from '#i18n'
import { IS_CLOUD_EDITION } from '@/config'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
import { apiReferencePathTranslations } from '@/types/doc-paths'
import { apiReferencePathTranslations, docPathProductAvailability } from '@/types/doc-paths'
export const useLocale = () => {
const { i18n } = useTranslation()
@ -24,6 +25,44 @@ export const useGetPricingPageLanguage = () => {
export const defaultDocBaseUrl = 'https://docs.dify.ai'
export type DocPathMap = Partial<Record<Locale, DocPathWithoutLang>>
export const getDocHomePath = () => '/home'
const getCurrentDocsProduct = (): DocsProduct => {
return IS_CLOUD_EDITION ? 'cloud' : 'self-host'
}
const splitPathHash = (path: string) => {
const hashIndex = path.indexOf('#')
if (hashIndex === -1) {
return {
pathname: path,
hash: '',
}
}
return {
pathname: path.slice(0, hashIndex),
hash: path.slice(hashIndex),
}
}
const getProductAwarePath = (path: string): string => {
const { pathname, hash } = splitPathHash(path)
const availableProducts = docPathProductAvailability[pathname]
if (!availableProducts?.length)
return path
const currentProduct = getCurrentDocsProduct()
const targetProduct = availableProducts.includes(currentProduct)
? currentProduct
: availableProducts[0]
if (!targetProduct)
return path
return `/${targetProduct}${pathname}${hash}`
}
export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => {
let baseDocUrl = baseUrl || defaultDocBaseUrl
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
@ -44,6 +83,12 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
}
}
}
else if (!targetPath) {
targetPath = getDocHomePath()
}
else {
targetPath = getProductAwarePath(targetPath)
}
return `${baseDocUrl}${languagePrefix}${targetPath}`
},

View File

@ -11,7 +11,8 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
const DEFAULT_DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
const DOCS_JSON_URL = process.env.DOCS_JSON_URL || DEFAULT_DOCS_JSON_URL
const OUTPUT_PATH = path.resolve(__dirname, '../types/doc-paths.ts')
type NavItem = string | NavObject | NavItem[]
@ -21,6 +22,9 @@ type NavObject = {
groups?: NavItem[]
dropdowns?: NavItem[]
languages?: NavItem[]
products?: NavItem[]
tabs?: NavItem[]
menu?: NavItem[]
versions?: NavItem[]
openapi?: string
[key: string]: unknown
@ -58,7 +62,15 @@ type DocsJson = {
[key: string]: unknown
}
const OPENAPI_BASE_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/'
const OPENAPI_BASE_URL = (process.env.DOCS_OPENAPI_BASE_URL || new URL('.', DOCS_JSON_URL).toString()).replace(/\/?$/, '/')
const DOCS_PRODUCTS = ['cloud', 'self-host'] as const
type DocsProduct = typeof DOCS_PRODUCTS[number]
type ProductAvailability = Record<string, Set<DocsProduct>>
function isDocsProduct(segment: string): segment is DocsProduct {
return DOCS_PRODUCTS.includes(segment as DocsProduct)
}
/**
* Convert summary to URL slug
@ -112,6 +124,15 @@ function extractOpenAPIPaths(item: NavItem | undefined, paths: Set<string> = new
if (item.languages)
extractOpenAPIPaths(item.languages, paths)
if (item.products)
extractOpenAPIPaths(item.products, paths)
if (item.tabs)
extractOpenAPIPaths(item.tabs, paths)
if (item.menu)
extractOpenAPIPaths(item.menu, paths)
if (item.versions)
extractOpenAPIPaths(item.versions, paths)
}
@ -199,6 +220,15 @@ function extractPaths(item: NavItem | undefined, paths: Set<string> = new Set())
if (item.languages)
extractPaths(item.languages, paths)
if (item.products)
extractPaths(item.products, paths)
if (item.tabs)
extractPaths(item.tabs, paths)
if (item.menu)
extractPaths(item.menu, paths)
// Handle versions in navigation
if (item.versions)
extractPaths(item.versions, paths)
@ -207,33 +237,68 @@ function extractPaths(item: NavItem | undefined, paths: Set<string> = new Set())
return paths
}
function addPathToGroup(groups: Record<string, Set<string>>, pathWithoutLang: string): void {
const parts = pathWithoutLang.split('/')
const section = parts[0]
if (!section)
return
if (!groups[section])
groups[section] = new Set()
groups[section]!.add(pathWithoutLang)
}
function getProductPathInfo(pathWithoutLang: string): { product: DocsProduct, pathWithoutProduct: string } | undefined {
const parts = pathWithoutLang.split('/')
const [product, ...rest] = parts
if (!product || !isDocsProduct(product) || rest.length === 0)
return undefined
return {
product,
pathWithoutProduct: rest.join('/'),
}
}
/**
* Group paths by their prefix structure
*/
function groupPathsBySection(paths: Set<string>): Record<string, Set<string>> {
function groupPathsBySection(paths: Set<string>): { groups: Record<string, Set<string>>, productAvailability: ProductAvailability } {
const groups: Record<string, Set<string>> = {}
const productAvailability: ProductAvailability = {}
for (const fullPath of paths) {
// Skip non-doc paths (like .json files for OpenAPI)
if (fullPath.endsWith('.json'))
continue
// Remove language prefix (en/, zh/, ja/)
const withoutLang = fullPath.replace(/^(en|zh|ja)\//, '')
if (!withoutLang || withoutLang === fullPath)
continue
// Get section (first part of path)
const parts = withoutLang.split('/')
const section = parts[0]
// Skip non-doc paths (like .json files for OpenAPI)
if (withoutLang.endsWith('.json') || withoutLang === 'None')
continue
if (!groups[section!])
groups[section!] = new Set()
addPathToGroup(groups, withoutLang)
groups[section!]!.add(withoutLang)
const productPathInfo = getProductPathInfo(withoutLang)
if (productPathInfo) {
const productlessPath = productPathInfo.pathWithoutProduct
const normalizedPath = `/${productlessPath}`
addPathToGroup(groups, productlessPath)
if (!productAvailability[normalizedPath])
productAvailability[normalizedPath] = new Set()
productAvailability[normalizedPath]!.add(productPathInfo.product)
}
}
return groups
return {
groups,
productAvailability,
}
}
/**
@ -251,6 +316,7 @@ function sectionToTypeName(section: string): string {
*/
function generateTypeDefinitions(
groups: Record<string, Set<string>>,
productAvailability: ProductAvailability,
apiReferencePaths: string[],
apiPathTranslations: Record<string, { zh?: string, ja?: string }>,
): string {
@ -258,11 +324,12 @@ function generateTypeDefinitions(
'// GENERATE BY script',
'// DON NOT EDIT IT MANUALLY',
'//',
'// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json',
`// Generated from: ${DOCS_JSON_URL}`,
`// Generated at: ${new Date().toISOString()}`,
'',
'// Language prefixes',
'export type DocLanguage = \'en\' | \'zh\' | \'ja\'',
'export type DocsProduct = \'cloud\' | \'self-host\'',
'',
]
@ -321,10 +388,14 @@ function generateTypeDefinitions(
lines.push(' | `${DocPathWithoutLangBase}#${string}`')
lines.push('')
// Generate full path type with language prefix
lines.push('// Full documentation path with language prefix')
// eslint-disable-next-line no-template-curly-in-string
lines.push('export type DifyDocPath = `${DocLanguage}/${DocPathWithoutLang}`')
// Generate product availability map for productless runtime links.
lines.push('// Product availability for productless docs paths')
lines.push('export const docPathProductAvailability: Record<string, readonly DocsProduct[]> = {')
for (const path of Object.keys(productAvailability).sort()) {
const products = [...productAvailability[path]!].sort((a, b) => DOCS_PRODUCTS.indexOf(a) - DOCS_PRODUCTS.indexOf(b))
lines.push(` '${path}': [${products.map(product => `'${product}'`).join(', ')}],`)
}
lines.push('}')
lines.push('')
// Generate API reference path translations map
@ -423,12 +494,13 @@ async function main(): Promise<void> {
console.log(`Generated ${Object.keys(apiPathTranslations).length} API path translations`)
// Group by section
const groups = groupPathsBySection(allPaths)
const { groups, productAvailability } = groupPathsBySection(allPaths)
console.log(`Grouped into ${Object.keys(groups).length} sections:`, Object.keys(groups))
console.log(`Found ${Object.keys(productAvailability).length} product-aware paths`)
// Generate TypeScript
const tsContent = generateTypeDefinitions(groups, uniqueEnApiPaths, apiPathTranslations)
const tsContent = generateTypeDefinitions(groups, productAvailability, uniqueEnApiPaths, apiPathTranslations)
// Write to file
await writeFile(OUTPUT_PATH, tsContent, 'utf-8')

View File

@ -1,28 +1,127 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
//
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json
// Generated at: 2026-03-25T03:18:49.626Z
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/feat/audience-products/docs.json
// Generated at: 2026-06-17T04:42:51.293Z
// Language prefixes
export type DocLanguage = 'en' | 'zh' | 'ja'
export type DocsProduct = 'cloud' | 'self-host'
// Cloud paths
type CloudPath =
| '/cloud/use-dify/build/additional-features'
| '/cloud/use-dify/build/agent'
| '/cloud/use-dify/build/chatbot'
| '/cloud/use-dify/build/mcp'
| '/cloud/use-dify/build/orchestrate-node'
| '/cloud/use-dify/build/predefined-error-handling-logic'
| '/cloud/use-dify/build/shortcut-key'
| '/cloud/use-dify/build/text-generator'
| '/cloud/use-dify/build/version-control'
| '/cloud/use-dify/build/workflow-chatflow'
| '/cloud/use-dify/build/workflow-collaboration'
| '/cloud/use-dify/debug/error-type'
| '/cloud/use-dify/debug/history-and-logs'
| '/cloud/use-dify/debug/step-run'
| '/cloud/use-dify/debug/variable-inspect'
| '/cloud/use-dify/getting-started/introduction'
| '/cloud/use-dify/knowledge/connect-external-knowledge-base'
| '/cloud/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/readme'
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion'
| '/cloud/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website'
| '/cloud/use-dify/knowledge/create-knowledge/introduction'
| '/cloud/use-dify/knowledge/create-knowledge/setting-indexing-methods'
| '/cloud/use-dify/knowledge/external-knowledge-api'
| '/cloud/use-dify/knowledge/integrate-knowledge-within-application'
| '/cloud/use-dify/knowledge/knowledge-pipeline/authorize-data-source'
| '/cloud/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline'
| '/cloud/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration'
| '/cloud/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base'
| '/cloud/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline'
| '/cloud/use-dify/knowledge/knowledge-pipeline/readme'
| '/cloud/use-dify/knowledge/knowledge-pipeline/upload-files'
| '/cloud/use-dify/knowledge/knowledge-request-rate-limit'
| '/cloud/use-dify/knowledge/manage-knowledge/introduction'
| '/cloud/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api'
| '/cloud/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents'
| '/cloud/use-dify/knowledge/metadata'
| '/cloud/use-dify/knowledge/readme'
| '/cloud/use-dify/knowledge/test-retrieval'
| '/cloud/use-dify/monitor/analysis'
| '/cloud/use-dify/monitor/annotation-reply'
| '/cloud/use-dify/monitor/integrations/integrate-aliyun'
| '/cloud/use-dify/monitor/integrations/integrate-arize'
| '/cloud/use-dify/monitor/integrations/integrate-langfuse'
| '/cloud/use-dify/monitor/integrations/integrate-langsmith'
| '/cloud/use-dify/monitor/integrations/integrate-opik'
| '/cloud/use-dify/monitor/integrations/integrate-phoenix'
| '/cloud/use-dify/monitor/integrations/integrate-weave'
| '/cloud/use-dify/monitor/logs'
| '/cloud/use-dify/nodes/agent'
| '/cloud/use-dify/nodes/answer'
| '/cloud/use-dify/nodes/code'
| '/cloud/use-dify/nodes/doc-extractor'
| '/cloud/use-dify/nodes/http-request'
| '/cloud/use-dify/nodes/human-input'
| '/cloud/use-dify/nodes/ifelse'
| '/cloud/use-dify/nodes/iteration'
| '/cloud/use-dify/nodes/knowledge-retrieval'
| '/cloud/use-dify/nodes/list-operator'
| '/cloud/use-dify/nodes/llm'
| '/cloud/use-dify/nodes/loop'
| '/cloud/use-dify/nodes/output'
| '/cloud/use-dify/nodes/parameter-extractor'
| '/cloud/use-dify/nodes/question-classifier'
| '/cloud/use-dify/nodes/template'
| '/cloud/use-dify/nodes/tools'
| '/cloud/use-dify/nodes/trigger/overview'
| '/cloud/use-dify/nodes/trigger/plugin-trigger'
| '/cloud/use-dify/nodes/trigger/schedule-trigger'
| '/cloud/use-dify/nodes/trigger/webhook-trigger'
| '/cloud/use-dify/nodes/user-input'
| '/cloud/use-dify/nodes/variable-aggregator'
| '/cloud/use-dify/nodes/variable-assigner'
| '/cloud/use-dify/publish/README'
| '/cloud/use-dify/publish/developing-with-apis'
| '/cloud/use-dify/publish/publish-mcp'
| '/cloud/use-dify/publish/publish-to-marketplace'
| '/cloud/use-dify/publish/webapp/chatflow-webapp'
| '/cloud/use-dify/publish/webapp/embedding-in-websites'
| '/cloud/use-dify/publish/webapp/web-app-settings'
| '/cloud/use-dify/publish/webapp/workflow-webapp'
| '/cloud/use-dify/workspace/api-extension/api-extension'
| '/cloud/use-dify/workspace/api-extension/cloudflare-worker'
| '/cloud/use-dify/workspace/api-extension/external-data-tool-api-extension'
| '/cloud/use-dify/workspace/api-extension/moderation-api-extension'
| '/cloud/use-dify/workspace/app-management'
| '/cloud/use-dify/workspace/model-providers'
| '/cloud/use-dify/workspace/personal-account-management'
| '/cloud/use-dify/workspace/plugins'
| '/cloud/use-dify/workspace/readme'
| '/cloud/use-dify/workspace/subscription-management'
| '/cloud/use-dify/workspace/team-members-management'
| '/cloud/use-dify/workspace/tools'
// UseDify paths
type UseDifyPath =
| '/use-dify/build/additional-features'
| '/use-dify/build/goto-anything'
| '/use-dify/build/agent'
| '/use-dify/build/chatbot'
| '/use-dify/build/mcp'
| '/use-dify/build/orchestrate-node'
| '/use-dify/build/predefined-error-handling-logic'
| '/use-dify/build/shortcut-key'
| '/use-dify/build/text-generator'
| '/use-dify/build/version-control'
| '/use-dify/build/workflow-chatflow'
| '/use-dify/build/workflow-collaboration'
| '/use-dify/debug/error-type'
| '/use-dify/debug/history-and-logs'
| '/use-dify/debug/step-run'
| '/use-dify/debug/variable-inspect'
| '/use-dify/getting-started/introduction'
| '/use-dify/getting-started/key-concepts'
| '/use-dify/getting-started/quick-start'
| '/use-dify/knowledge/connect-external-knowledge-base'
| '/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
| '/use-dify/knowledge/create-knowledge/import-text-data/readme'
@ -86,24 +185,8 @@ type UseDifyPath =
| '/use-dify/publish/publish-to-marketplace'
| '/use-dify/publish/webapp/chatflow-webapp'
| '/use-dify/publish/webapp/embedding-in-websites'
| '/use-dify/publish/webapp/web-app-access'
| '/use-dify/publish/webapp/web-app-settings'
| '/use-dify/publish/webapp/workflow-webapp'
| '/use-dify/tutorials/article-reader'
| '/use-dify/tutorials/build-ai-image-generation-app'
| '/use-dify/tutorials/customer-service-bot'
| '/use-dify/tutorials/simple-chatbot'
| '/use-dify/tutorials/twitter-chatflow'
| '/use-dify/tutorials/workflow-101/lesson-01'
| '/use-dify/tutorials/workflow-101/lesson-02'
| '/use-dify/tutorials/workflow-101/lesson-03'
| '/use-dify/tutorials/workflow-101/lesson-04'
| '/use-dify/tutorials/workflow-101/lesson-05'
| '/use-dify/tutorials/workflow-101/lesson-06'
| '/use-dify/tutorials/workflow-101/lesson-07'
| '/use-dify/tutorials/workflow-101/lesson-08'
| '/use-dify/tutorials/workflow-101/lesson-09'
| '/use-dify/tutorials/workflow-101/lesson-10'
| '/use-dify/workspace/api-extension/api-extension'
| '/use-dify/workspace/api-extension/cloudflare-worker'
| '/use-dify/workspace/api-extension/external-data-tool-api-extension'
@ -115,25 +198,42 @@ type UseDifyPath =
| '/use-dify/workspace/readme'
| '/use-dify/workspace/subscription-management'
| '/use-dify/workspace/team-members-management'
| '/use-dify/workspace/tools'
// UseDify node paths (without prefix)
type ExtractNodesPath<T> = T extends `/use-dify/nodes/${infer Path}` ? Path : never
export type UseDifyNodesPath = ExtractNodesPath<UseDifyPath>
// SelfHost paths
type SelfHostPath =
| '/self-host/advanced-deployments/local-source-code'
| '/self-host/advanced-deployments/start-the-frontend-docker-container'
| '/self-host/configuration/environments'
| '/self-host/platform-guides/bt-panel'
| '/self-host/platform-guides/dify-premium'
| '/self-host/quick-start/docker-compose'
| '/self-host/quick-start/faqs'
| '/self-host/troubleshooting/common-issues'
| '/self-host/troubleshooting/docker-issues'
| '/self-host/troubleshooting/integrations'
| '/self-host/troubleshooting/storage-and-migration'
| '/self-host/troubleshooting/weaviate-v4-migration'
// Home paths
type HomePath =
| '/home'
// Learn paths
type LearnPath =
| '/learn/key-concepts'
| '/learn/tutorials/article-reader'
| '/learn/tutorials/build-ai-image-generation-app'
| '/learn/tutorials/customer-service-bot'
| '/learn/tutorials/simple-chatbot'
| '/learn/tutorials/twitter-chatflow'
| '/learn/tutorials/workflow-101/lesson-01'
| '/learn/tutorials/workflow-101/lesson-02'
| '/learn/tutorials/workflow-101/lesson-03'
| '/learn/tutorials/workflow-101/lesson-04'
| '/learn/tutorials/workflow-101/lesson-05'
| '/learn/tutorials/workflow-101/lesson-06'
| '/learn/tutorials/workflow-101/lesson-07'
| '/learn/tutorials/workflow-101/lesson-08'
| '/learn/tutorials/workflow-101/lesson-09'
| '/learn/tutorials/workflow-101/lesson-10'
// QuickStart paths
type QuickStartPath =
| '/quick-start'
// Cli paths
type CliPath =
| '/cli/coming-soon'
// DevelopPlugin paths
type DevelopPluginPath =
@ -165,6 +265,7 @@ type DevelopPluginPath =
| '/develop-plugin/features-and-specs/plugin-types/plugin-logging'
| '/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin'
| '/develop-plugin/features-and-specs/plugin-types/tool'
| '/develop-plugin/getting-started/choose-plugin-type'
| '/develop-plugin/getting-started/cli'
| '/develop-plugin/getting-started/getting-started-dify-plugin'
| '/develop-plugin/publishing/faq/faq'
@ -177,6 +278,129 @@ type DevelopPluginPath =
| '/develop-plugin/publishing/standards/privacy-protection-guidelines'
| '/develop-plugin/publishing/standards/third-party-signature-verification'
// SelfHost paths
type SelfHostPath =
| '/self-host/deploy/advanced-deployments/local-source-code'
| '/self-host/deploy/advanced-deployments/start-the-frontend-docker-container'
| '/self-host/deploy/configuration/environments'
| '/self-host/deploy/overview'
| '/self-host/deploy/platform-guides/bt-panel'
| '/self-host/deploy/platform-guides/dify-premium'
| '/self-host/deploy/quick-start/docker-compose'
| '/self-host/deploy/quick-start/faqs'
| '/self-host/deploy/troubleshooting/common-issues'
| '/self-host/deploy/troubleshooting/docker-issues'
| '/self-host/deploy/troubleshooting/integrations'
| '/self-host/deploy/troubleshooting/storage-and-migration'
| '/self-host/deploy/troubleshooting/weaviate-v4-migration'
| '/self-host/use-dify/build/additional-features'
| '/self-host/use-dify/build/agent'
| '/self-host/use-dify/build/chatbot'
| '/self-host/use-dify/build/mcp'
| '/self-host/use-dify/build/orchestrate-node'
| '/self-host/use-dify/build/predefined-error-handling-logic'
| '/self-host/use-dify/build/shortcut-key'
| '/self-host/use-dify/build/text-generator'
| '/self-host/use-dify/build/version-control'
| '/self-host/use-dify/build/workflow-chatflow'
| '/self-host/use-dify/build/workflow-collaboration'
| '/self-host/use-dify/debug/error-type'
| '/self-host/use-dify/debug/history-and-logs'
| '/self-host/use-dify/debug/step-run'
| '/self-host/use-dify/debug/variable-inspect'
| '/self-host/use-dify/getting-started/introduction'
| '/self-host/use-dify/knowledge/connect-external-knowledge-base'
| '/self-host/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text'
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/readme'
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion'
| '/self-host/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website'
| '/self-host/use-dify/knowledge/create-knowledge/introduction'
| '/self-host/use-dify/knowledge/create-knowledge/setting-indexing-methods'
| '/self-host/use-dify/knowledge/external-knowledge-api'
| '/self-host/use-dify/knowledge/integrate-knowledge-within-application'
| '/self-host/use-dify/knowledge/knowledge-pipeline/authorize-data-source'
| '/self-host/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline'
| '/self-host/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration'
| '/self-host/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base'
| '/self-host/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline'
| '/self-host/use-dify/knowledge/knowledge-pipeline/readme'
| '/self-host/use-dify/knowledge/knowledge-pipeline/upload-files'
| '/self-host/use-dify/knowledge/manage-knowledge/introduction'
| '/self-host/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api'
| '/self-host/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents'
| '/self-host/use-dify/knowledge/metadata'
| '/self-host/use-dify/knowledge/readme'
| '/self-host/use-dify/knowledge/test-retrieval'
| '/self-host/use-dify/monitor/analysis'
| '/self-host/use-dify/monitor/annotation-reply'
| '/self-host/use-dify/monitor/integrations/integrate-aliyun'
| '/self-host/use-dify/monitor/integrations/integrate-arize'
| '/self-host/use-dify/monitor/integrations/integrate-langfuse'
| '/self-host/use-dify/monitor/integrations/integrate-langsmith'
| '/self-host/use-dify/monitor/integrations/integrate-opik'
| '/self-host/use-dify/monitor/integrations/integrate-phoenix'
| '/self-host/use-dify/monitor/integrations/integrate-weave'
| '/self-host/use-dify/monitor/logs'
| '/self-host/use-dify/nodes/agent'
| '/self-host/use-dify/nodes/answer'
| '/self-host/use-dify/nodes/code'
| '/self-host/use-dify/nodes/doc-extractor'
| '/self-host/use-dify/nodes/http-request'
| '/self-host/use-dify/nodes/human-input'
| '/self-host/use-dify/nodes/ifelse'
| '/self-host/use-dify/nodes/iteration'
| '/self-host/use-dify/nodes/knowledge-retrieval'
| '/self-host/use-dify/nodes/list-operator'
| '/self-host/use-dify/nodes/llm'
| '/self-host/use-dify/nodes/loop'
| '/self-host/use-dify/nodes/output'
| '/self-host/use-dify/nodes/parameter-extractor'
| '/self-host/use-dify/nodes/question-classifier'
| '/self-host/use-dify/nodes/template'
| '/self-host/use-dify/nodes/tools'
| '/self-host/use-dify/nodes/trigger/overview'
| '/self-host/use-dify/nodes/trigger/plugin-trigger'
| '/self-host/use-dify/nodes/trigger/schedule-trigger'
| '/self-host/use-dify/nodes/trigger/webhook-trigger'
| '/self-host/use-dify/nodes/user-input'
| '/self-host/use-dify/nodes/variable-aggregator'
| '/self-host/use-dify/nodes/variable-assigner'
| '/self-host/use-dify/publish/README'
| '/self-host/use-dify/publish/developing-with-apis'
| '/self-host/use-dify/publish/publish-mcp'
| '/self-host/use-dify/publish/publish-to-marketplace'
| '/self-host/use-dify/publish/webapp/chatflow-webapp'
| '/self-host/use-dify/publish/webapp/embedding-in-websites'
| '/self-host/use-dify/publish/webapp/web-app-settings'
| '/self-host/use-dify/publish/webapp/workflow-webapp'
| '/self-host/use-dify/workspace/api-extension/api-extension'
| '/self-host/use-dify/workspace/api-extension/cloudflare-worker'
| '/self-host/use-dify/workspace/api-extension/external-data-tool-api-extension'
| '/self-host/use-dify/workspace/api-extension/moderation-api-extension'
| '/self-host/use-dify/workspace/app-management'
| '/self-host/use-dify/workspace/model-providers'
| '/self-host/use-dify/workspace/personal-account-management'
| '/self-host/use-dify/workspace/plugins'
| '/self-host/use-dify/workspace/readme'
| '/self-host/use-dify/workspace/team-members-management'
| '/self-host/use-dify/workspace/tools'
// Deploy paths
type DeployPath =
| '/deploy/advanced-deployments/local-source-code'
| '/deploy/advanced-deployments/start-the-frontend-docker-container'
| '/deploy/configuration/environments'
| '/deploy/overview'
| '/deploy/platform-guides/bt-panel'
| '/deploy/platform-guides/dify-premium'
| '/deploy/quick-start/docker-compose'
| '/deploy/quick-start/faqs'
| '/deploy/troubleshooting/common-issues'
| '/deploy/troubleshooting/docker-issues'
| '/deploy/troubleshooting/integrations'
| '/deploy/troubleshooting/storage-and-migration'
| '/deploy/troubleshooting/weaviate-v4-migration'
// API Reference paths (English, use apiReferencePathTranslations for other languages)
type ApiReferencePath =
| '/api-reference/annotations/configure-annotation-reply'
@ -189,6 +413,12 @@ type ApiReferencePath =
| '/api-reference/applications/get-app-meta'
| '/api-reference/applications/get-app-parameters'
| '/api-reference/applications/get-app-webapp-settings'
| '/api-reference/chatflows/get-next-suggested-questions'
| '/api-reference/chatflows/get-workflow-run-detail'
| '/api-reference/chatflows/list-workflow-logs'
| '/api-reference/chatflows/send-chat-message'
| '/api-reference/chatflows/stop-chat-message-generation'
| '/api-reference/chatflows/stream-workflow-events'
| '/api-reference/chats/get-next-suggested-questions'
| '/api-reference/chats/send-chat-message'
| '/api-reference/chats/stop-chat-message-generation'
@ -225,6 +455,8 @@ type ApiReferencePath =
| '/api-reference/feedback/submit-message-feedback'
| '/api-reference/files/download-file'
| '/api-reference/files/upload-file'
| '/api-reference/human-input/get-human-input-form'
| '/api-reference/human-input/submit-human-input-form'
| '/api-reference/knowledge-bases/create-an-empty-knowledge-base'
| '/api-reference/knowledge-bases/delete-knowledge-base'
| '/api-reference/knowledge-bases/get-knowledge-base'
@ -252,19 +484,24 @@ type ApiReferencePath =
| '/api-reference/tags/update-knowledge-tag'
| '/api-reference/tts/convert-audio-to-text'
| '/api-reference/tts/convert-text-to-audio'
| '/api-reference/workflow-runs/get-workflow-run-detail'
| '/api-reference/workflow-runs/list-workflow-logs'
| '/api-reference/workflows/get-workflow-run-detail'
| '/api-reference/workflows/list-workflow-logs'
| '/api-reference/workflows/run-workflow'
| '/api-reference/workflows/run-workflow-by-id'
| '/api-reference/workflows/stop-workflow-task'
| '/api-reference/workflows/stream-workflow-events'
// Base path without language prefix
type DocPathWithoutLangBase =
| CloudPath
| UseDifyPath
| SelfHostPath
| HomePath
| LearnPath
| QuickStartPath
| CliPath
| DevelopPluginPath
| SelfHostPath
| DeployPath
| ApiReferencePath
// Combined path without language prefix (supports optional #anchor)
@ -272,6 +509,116 @@ export type DocPathWithoutLang =
| DocPathWithoutLangBase
| `${DocPathWithoutLangBase}#${string}`
// Product availability for productless docs paths
export const docPathProductAvailability: Record<string, readonly DocsProduct[]> = {
'/deploy/advanced-deployments/local-source-code': ['self-host'],
'/deploy/advanced-deployments/start-the-frontend-docker-container': ['self-host'],
'/deploy/configuration/environments': ['self-host'],
'/deploy/overview': ['self-host'],
'/deploy/platform-guides/bt-panel': ['self-host'],
'/deploy/platform-guides/dify-premium': ['self-host'],
'/deploy/quick-start/docker-compose': ['self-host'],
'/deploy/quick-start/faqs': ['self-host'],
'/deploy/troubleshooting/common-issues': ['self-host'],
'/deploy/troubleshooting/docker-issues': ['self-host'],
'/deploy/troubleshooting/integrations': ['self-host'],
'/deploy/troubleshooting/storage-and-migration': ['self-host'],
'/deploy/troubleshooting/weaviate-v4-migration': ['self-host'],
'/use-dify/build/additional-features': ['cloud', 'self-host'],
'/use-dify/build/agent': ['cloud', 'self-host'],
'/use-dify/build/chatbot': ['cloud', 'self-host'],
'/use-dify/build/mcp': ['cloud', 'self-host'],
'/use-dify/build/orchestrate-node': ['cloud', 'self-host'],
'/use-dify/build/predefined-error-handling-logic': ['cloud', 'self-host'],
'/use-dify/build/shortcut-key': ['cloud', 'self-host'],
'/use-dify/build/text-generator': ['cloud', 'self-host'],
'/use-dify/build/version-control': ['cloud', 'self-host'],
'/use-dify/build/workflow-chatflow': ['cloud', 'self-host'],
'/use-dify/build/workflow-collaboration': ['cloud', 'self-host'],
'/use-dify/debug/error-type': ['cloud', 'self-host'],
'/use-dify/debug/history-and-logs': ['cloud', 'self-host'],
'/use-dify/debug/step-run': ['cloud', 'self-host'],
'/use-dify/debug/variable-inspect': ['cloud', 'self-host'],
'/use-dify/getting-started/introduction': ['cloud', 'self-host'],
'/use-dify/knowledge/connect-external-knowledge-base': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/import-text-data/readme': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/import-text-data/sync-from-notion': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/import-text-data/sync-from-website': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/introduction': ['cloud', 'self-host'],
'/use-dify/knowledge/create-knowledge/setting-indexing-methods': ['cloud', 'self-host'],
'/use-dify/knowledge/external-knowledge-api': ['cloud', 'self-host'],
'/use-dify/knowledge/integrate-knowledge-within-application': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/authorize-data-source': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/create-knowledge-pipeline': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/manage-knowledge-base': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/publish-knowledge-pipeline': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/readme': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-pipeline/upload-files': ['cloud', 'self-host'],
'/use-dify/knowledge/knowledge-request-rate-limit': ['cloud'],
'/use-dify/knowledge/manage-knowledge/introduction': ['cloud', 'self-host'],
'/use-dify/knowledge/manage-knowledge/maintain-dataset-via-api': ['cloud', 'self-host'],
'/use-dify/knowledge/manage-knowledge/maintain-knowledge-documents': ['cloud', 'self-host'],
'/use-dify/knowledge/metadata': ['cloud', 'self-host'],
'/use-dify/knowledge/readme': ['cloud', 'self-host'],
'/use-dify/knowledge/test-retrieval': ['cloud', 'self-host'],
'/use-dify/monitor/analysis': ['cloud', 'self-host'],
'/use-dify/monitor/annotation-reply': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-aliyun': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-arize': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-langfuse': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-langsmith': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-opik': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-phoenix': ['cloud', 'self-host'],
'/use-dify/monitor/integrations/integrate-weave': ['cloud', 'self-host'],
'/use-dify/monitor/logs': ['cloud', 'self-host'],
'/use-dify/nodes/agent': ['cloud', 'self-host'],
'/use-dify/nodes/answer': ['cloud', 'self-host'],
'/use-dify/nodes/code': ['cloud', 'self-host'],
'/use-dify/nodes/doc-extractor': ['cloud', 'self-host'],
'/use-dify/nodes/http-request': ['cloud', 'self-host'],
'/use-dify/nodes/human-input': ['cloud', 'self-host'],
'/use-dify/nodes/ifelse': ['cloud', 'self-host'],
'/use-dify/nodes/iteration': ['cloud', 'self-host'],
'/use-dify/nodes/knowledge-retrieval': ['cloud', 'self-host'],
'/use-dify/nodes/list-operator': ['cloud', 'self-host'],
'/use-dify/nodes/llm': ['cloud', 'self-host'],
'/use-dify/nodes/loop': ['cloud', 'self-host'],
'/use-dify/nodes/output': ['cloud', 'self-host'],
'/use-dify/nodes/parameter-extractor': ['cloud', 'self-host'],
'/use-dify/nodes/question-classifier': ['cloud', 'self-host'],
'/use-dify/nodes/template': ['cloud', 'self-host'],
'/use-dify/nodes/tools': ['cloud', 'self-host'],
'/use-dify/nodes/trigger/overview': ['cloud', 'self-host'],
'/use-dify/nodes/trigger/plugin-trigger': ['cloud', 'self-host'],
'/use-dify/nodes/trigger/schedule-trigger': ['cloud', 'self-host'],
'/use-dify/nodes/trigger/webhook-trigger': ['cloud', 'self-host'],
'/use-dify/nodes/user-input': ['cloud', 'self-host'],
'/use-dify/nodes/variable-aggregator': ['cloud', 'self-host'],
'/use-dify/nodes/variable-assigner': ['cloud', 'self-host'],
'/use-dify/publish/README': ['cloud', 'self-host'],
'/use-dify/publish/developing-with-apis': ['cloud', 'self-host'],
'/use-dify/publish/publish-mcp': ['cloud', 'self-host'],
'/use-dify/publish/publish-to-marketplace': ['cloud', 'self-host'],
'/use-dify/publish/webapp/chatflow-webapp': ['cloud', 'self-host'],
'/use-dify/publish/webapp/embedding-in-websites': ['cloud', 'self-host'],
'/use-dify/publish/webapp/web-app-settings': ['cloud', 'self-host'],
'/use-dify/publish/webapp/workflow-webapp': ['cloud', 'self-host'],
'/use-dify/workspace/api-extension/api-extension': ['cloud', 'self-host'],
'/use-dify/workspace/api-extension/cloudflare-worker': ['cloud', 'self-host'],
'/use-dify/workspace/api-extension/external-data-tool-api-extension': ['cloud', 'self-host'],
'/use-dify/workspace/api-extension/moderation-api-extension': ['cloud', 'self-host'],
'/use-dify/workspace/app-management': ['cloud', 'self-host'],
'/use-dify/workspace/model-providers': ['cloud', 'self-host'],
'/use-dify/workspace/personal-account-management': ['cloud', 'self-host'],
'/use-dify/workspace/plugins': ['cloud', 'self-host'],
'/use-dify/workspace/readme': ['cloud', 'self-host'],
'/use-dify/workspace/subscription-management': ['cloud'],
'/use-dify/workspace/team-members-management': ['cloud', 'self-host'],
'/use-dify/workspace/tools': ['cloud', 'self-host'],
}
// API Reference path translations (English -> other languages)
export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: string }> = {
'/api-reference/annotations/configure-annotation-reply': { zh: '/api-reference/标注管理/配置标注回复', ja: '/api-reference/アノテーション管理/アノテーション返信を設定' },
@ -284,6 +631,12 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
'/api-reference/applications/get-app-meta': { zh: '/api-reference/应用配置/获取应用元数据', ja: '/api-reference/アプリケーション設定/アプリケーションのメタ情報を取得' },
'/api-reference/applications/get-app-parameters': { zh: '/api-reference/应用配置/获取应用参数', ja: '/api-reference/アプリケーション設定/アプリケーションのパラメータ情報を取得' },
'/api-reference/applications/get-app-webapp-settings': { zh: '/api-reference/应用配置/获取应用-webapp-设置', ja: '/api-reference/アプリケーション設定/アプリの-webapp-設定を取得' },
'/api-reference/chatflows/get-next-suggested-questions': { zh: '/api-reference/对话流/获取下一轮建议问题列表', ja: '/api-reference/チャットフロー/次の推奨質問を取得' },
'/api-reference/chatflows/get-workflow-run-detail': { zh: '/api-reference/对话流/获取工作流执行情况', ja: '/api-reference/チャットフロー/ワークフロー実行詳細を取得' },
'/api-reference/chatflows/list-workflow-logs': { zh: '/api-reference/对话流/获取工作流日志', ja: '/api-reference/チャットフロー/ワークフローログ一覧を取得' },
'/api-reference/chatflows/send-chat-message': { zh: '/api-reference/对话流/发送对话消息', ja: '/api-reference/チャットフロー/チャットメッセージを送信' },
'/api-reference/chatflows/stop-chat-message-generation': { zh: '/api-reference/对话流/停止响应', ja: '/api-reference/チャットフロー/生成を停止' },
'/api-reference/chatflows/stream-workflow-events': { zh: '/api-reference/对话流/流式获取工作流事件', ja: '/api-reference/チャットフロー/ワークフローイベントをストリーム' },
'/api-reference/chats/get-next-suggested-questions': { zh: '/api-reference/对话消息/获取下一轮建议问题列表', ja: '/api-reference/チャットメッセージ/次の推奨質問を取得' },
'/api-reference/chats/send-chat-message': { zh: '/api-reference/对话消息/发送对话消息', ja: '/api-reference/チャットメッセージ/チャットメッセージを送信' },
'/api-reference/chats/stop-chat-message-generation': { zh: '/api-reference/对话消息/停止响应', ja: '/api-reference/チャットメッセージ/生成を停止' },
@ -320,6 +673,8 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
'/api-reference/feedback/submit-message-feedback': { zh: '/api-reference/消息反馈/提交消息反馈', ja: '/api-reference/メッセージフィードバック/メッセージフィードバックを送信' },
'/api-reference/files/download-file': { zh: '/api-reference/文件操作/下载文件', ja: '/api-reference/ファイル操作/ファイルをダウンロード' },
'/api-reference/files/upload-file': { zh: '/api-reference/文件操作/上传文件', ja: '/api-reference/ファイル操作/ファイルをアップロード' },
'/api-reference/human-input/get-human-input-form': { zh: '/api-reference/人工介入/获取人工介入表单', ja: '/api-reference/人間の入力/人間の入力フォームを取得' },
'/api-reference/human-input/submit-human-input-form': { zh: '/api-reference/人工介入/提交人工介入表单', ja: '/api-reference/人間の入力/人間の入力フォームを送信' },
'/api-reference/knowledge-bases/create-an-empty-knowledge-base': { zh: '/api-reference/知识库/创建空知识库', ja: '/api-reference/データセット/空のナレッジベースを作成' },
'/api-reference/knowledge-bases/delete-knowledge-base': { zh: '/api-reference/知识库/删除知识库', ja: '/api-reference/データセット/ナレッジベースを削除' },
'/api-reference/knowledge-bases/get-knowledge-base': { zh: '/api-reference/知识库/获取知识库详情', ja: '/api-reference/データセット/ナレッジベース詳細を取得' },
@ -347,11 +702,10 @@ export const apiReferencePathTranslations: Record<string, { zh?: string; ja?: st
'/api-reference/tags/update-knowledge-tag': { zh: '/api-reference/标签/修改知识库标签', ja: '/api-reference/タグ管理/ナレッジベースタグを変更' },
'/api-reference/tts/convert-audio-to-text': { zh: '/api-reference/语音与文字转换/语音转文字', ja: '/api-reference/音声・テキスト変換/音声をテキストに変換' },
'/api-reference/tts/convert-text-to-audio': { zh: '/api-reference/语音与文字转换/文字转语音', ja: '/api-reference/音声・テキスト変換/テキストを音声に変換' },
'/api-reference/workflow-runs/get-workflow-run-detail': { zh: '/api-reference/工作流执行/获取工作流执行情况', ja: '/api-reference/ワークフロー実行/ワークフロー実行詳細を取得' },
'/api-reference/workflow-runs/list-workflow-logs': { zh: '/api-reference/工作流执行/获取工作流日志', ja: '/api-reference/ワークフロー実行/ワークフローログ一覧を取得' },
'/api-reference/workflows/get-workflow-run-detail': { zh: '/api-reference/工作流/获取工作流执行情况', ja: '/api-reference/ワークフロー/ワークフロー実行詳細を取得' },
'/api-reference/workflows/list-workflow-logs': { zh: '/api-reference/工作流/获取工作流日志', ja: '/api-reference/ワークフロー/ワークフローログ一覧を取得' },
'/api-reference/workflows/run-workflow': { zh: '/api-reference/工作流/执行工作流', ja: '/api-reference/ワークフロー/ワークフローを実行' },
'/api-reference/workflows/run-workflow-by-id': { zh: '/api-reference/工作流/按-id-执行工作流', ja: '/api-reference/ワークフロー/id-でワークフローを実行' },
'/api-reference/workflows/stop-workflow-task': { zh: '/api-reference/工作流/停止工作流任务', ja: '/api-reference/ワークフロー/ワークフロータスクを停止' },
'/api-reference/workflows/stream-workflow-events': { zh: '/api-reference/工作流/流式获取工作流事件', ja: '/api-reference/ワークフロー/ワークフローイベントをストリーム' },
}