Compare commits

..

4 Commits

Author SHA1 Message Date
yyh
b339a66403 test: remove unused cleanup test helper argument 2026-06-17 13:15:20 +08:00
yyh
d3c27d9d89 fix(agent-v2): include workflow references in agent list 2026-06-17 13:10:58 +08:00
f992ede836 test: replace logger patch with caplog in remaining test files (#37562) 2026-06-17 03:37:09 +00:00
6ab5cf109b refactor: optimize free plan workflow run cleanup batching (#37227)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 03:19:26 +00:00
32 changed files with 1100 additions and 909 deletions

View File

@ -2,7 +2,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import AliasChoices, BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@ -10,6 +10,7 @@ from controllers.console.app.app import (
AppDetailWithSite,
AppListQuery,
AppPagination,
AppPartial,
UpdateAppPayload,
_normalize_app_list_query_args,
)
@ -63,6 +64,16 @@ class AgentAppUpdatePayload(UpdateAppPayload):
role: str | None = Field(default=None, description="Agent role", max_length=255)
class AgentAppPartial(AppPartial):
published_reference_count: int = 0
published_node_reference_count: int = 0
published_references: list[AgentPublishedReferenceResponse] = Field(default_factory=list)
class AgentAppPagination(AppPagination):
data: list[AgentAppPartial] = Field(validation_alias=AliasChoices("items", "data"))
register_schema_models(
console_ns,
AgentAppCreatePayload,
@ -76,7 +87,7 @@ register_schema_models(
register_response_schema_models(
console_ns,
AppDetailWithSite,
AppPagination,
AgentAppPagination,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
@ -122,6 +133,10 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
)
published_references_by_agent_id = roster_service.load_published_references_by_agent_id(
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()],
)
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
@ -132,7 +147,16 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
item["id"] = agent.id
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
return payload
published_references = published_references_by_agent_id.get(agent.id, [])
item["published_reference_count"] = len(published_references)
item["published_node_reference_count"] = sum(
len(reference["node_ids"]) for reference in published_references
)
item["published_references"] = published_references
return AgentAppPagination.model_validate(payload).model_dump(
mode="json",
exclude={"data": {"__all__": {"bound_agent_id"}}},
)
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
@ -142,7 +166,7 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
@console_ns.route("/agent")
class AgentAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__])
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@ -163,7 +187,7 @@ class AgentAppListApi(Resource):
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params)
if app_pagination is None:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)

View File

@ -35,6 +35,7 @@ Example:
"""
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, TypedDict
@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict):
pause_reasons: int
@dataclass(frozen=True)
class WorkflowRunCleanupRef:
"""
Lightweight workflow run reference for retention cleanup scans.
Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the
query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped
after billing checks.
"""
id: str
tenant_id: str
created_at: datetime
class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
Protocol for service-layer WorkflowRun repository operations.
@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime | None,
end_before: datetime,
last_seen: tuple[datetime, str] | None,
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
upper_bound: tuple[datetime, str] | None = None,
) -> Sequence[WorkflowRunCleanupRef]:
"""
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
Args:
start_from: Optional inclusive lower time boundary.
end_before: Exclusive upper time boundary.
last_seen: Optional exclusive `(created_at, id)` cursor lower bound.
batch_size: Maximum number of refs to return.
run_types: Optional workflow type filter.
tenant_ids: Optional tenant filter.
workflow_ids: Optional workflow ID filter.
upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second,
tenant-filtered target query that must stay within the candidate page high-water cursor.
Returns:
Ordered lightweight cleanup refs containing only id, tenant_id, and created_at.
"""
...
def get_archived_run_ids(
self,
session: Session,
@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def delete_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
"""
Delete workflow runs and cleanup-owned related records by workflow run IDs.
This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models.
"""
...
def get_app_logs_by_run_id(
self,
session: Session,
@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
"""
...
def count_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
"""
Count workflow runs and cleanup-owned related records by workflow run IDs.
This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models.
"""
...
def create_workflow_pause(
self,
workflow_run_id: str,

View File

@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold
from models.enums import WorkflowRunTriggeredFrom
from models.human_input import HumanInputForm, HumanInputFormRecipient
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
RunsWithRelatedCountsDict,
WorkflowRunCleanupRef,
)
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.types import (
AverageInteractionStats,
@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
return session.scalars(stmt).all()
@override
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime | None,
end_before: datetime,
last_seen: tuple[datetime, str] | None,
batch_size: int,
run_types: Sequence[WorkflowType] | None = None,
tenant_ids: Sequence[str] | None = None,
workflow_ids: Sequence[str] | None = None,
upper_bound: tuple[datetime, str] | None = None,
) -> Sequence[WorkflowRunCleanupRef]:
"""
Fetch lightweight ended workflow run refs in a time window for cleanup batching.
The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second,
tenant-filtered target query stays within the candidate page already checked against billing.
"""
with self._session_maker() as session:
stmt = (
select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at)
.where(
WorkflowRun.created_at < end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
)
.order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc())
.limit(batch_size)
)
if run_types is not None:
if not run_types:
return []
stmt = stmt.where(WorkflowRun.type.in_(run_types))
if start_from:
stmt = stmt.where(WorkflowRun.created_at >= start_from)
if tenant_ids:
stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids))
if workflow_ids:
stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids))
if last_seen:
stmt = stmt.where(
tuple_(WorkflowRun.created_at, WorkflowRun.id)
> tuple_(
sa.literal(last_seen[0], type_=sa.DateTime()),
sa.literal(last_seen[1], type_=WorkflowRun.id.type),
)
)
if upper_bound:
stmt = stmt.where(
tuple_(WorkflowRun.created_at, WorkflowRun.id)
<= tuple_(
sa.literal(upper_bound[0], type_=sa.DateTime()),
sa.literal(upper_bound[1], type_=WorkflowRun.id.type),
)
)
return [
WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
for run_id, tenant_id, created_at in session.execute(stmt).all()
]
@override
def get_archived_run_ids(
self,
@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"pause_reasons": pause_reasons_deleted,
}
@override
def delete_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
if not run_ids:
return self._empty_runs_with_related_counts()
run_ids = list(run_ids)
with self._session_maker() as session:
if delete_node_executions:
node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids)
else:
node_executions_deleted, offloads_deleted = 0, 0
app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)))
app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0
pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
pause_ids = session.scalars(pause_stmt).all()
pause_reasons_deleted = 0
pauses_deleted = 0
if pause_ids:
pause_reasons_result = session.execute(
delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids))
)
pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0
pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids)))
pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0
trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0
runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids)))
runs_deleted = cast(CursorResult, runs_result).rowcount or 0
session.commit()
return {
"runs": runs_deleted,
"node_executions": node_executions_deleted,
"offloads": offloads_deleted,
"app_logs": app_logs_deleted,
"trigger_logs": trigger_logs_deleted,
"pauses": pauses_deleted,
"pause_reasons": pause_reasons_deleted,
}
@override
def get_app_logs_by_run_id(
self,
@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"pause_reasons": int(pause_reasons_count),
}
@override
def count_runs_with_related_by_ids(
self,
run_ids: Sequence[str],
count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None,
count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None,
) -> RunsWithRelatedCountsDict:
if not run_ids:
return self._empty_runs_with_related_counts()
run_ids = list(run_ids)
with self._session_maker() as session:
if count_node_executions:
node_executions_count, offloads_count = count_node_executions(session, run_ids)
else:
node_executions_count, offloads_count = 0, 0
runs_count = (
session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0
)
app_logs_count = (
session.scalar(
select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))
)
or 0
)
pause_ids = session.scalars(
select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids))
).all()
pauses_count = len(pause_ids)
pause_reasons_count = 0
if pause_ids:
pause_reasons_count = (
session.scalar(
select(func.count())
.select_from(WorkflowPauseReason)
.where(WorkflowPauseReason.pause_id.in_(pause_ids))
)
or 0
)
trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0
return {
"runs": int(runs_count),
"node_executions": node_executions_count,
"offloads": offloads_count,
"app_logs": int(app_logs_count),
"trigger_logs": trigger_logs_count,
"pauses": pauses_count,
"pause_reasons": int(pause_reasons_count),
}
@staticmethod
def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict:
return {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
@override
def create_workflow_pause(
self,

View File

@ -432,6 +432,12 @@ class AgentRosterService:
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, [])
def load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:
"""Return published workflow references grouped by roster Agent id."""
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids)
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
active_version = self._get_version(

View File

@ -1,3 +1,10 @@
"""Cleanup expired workflow run logs for free-plan tenants.
The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and
deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same
candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models.
"""
import datetime
import logging
import random
@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from models.workflow import WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
RunsWithRelatedCountsDict,
WorkflowRunCleanupRef,
)
from repositories.factory import DifyAPIRepositoryFactory
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.billing_service import BillingService, SubscriptionPlan
@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs
class WorkflowRunCleanup:
"""
Coordinates free-plan workflow run retention cleanup.
The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable
when billing filters out paid or unknown tenants before the repository performs the target lookup.
"""
def __init__(
self,
days: int,
@ -254,26 +271,28 @@ class WorkflowRunCleanup:
batch_start = time.monotonic()
fetch_start = time.monotonic()
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
candidate_last_seen = last_seen
candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=last_seen,
last_seen=candidate_last_seen,
batch_size=self.batch_size,
)
if not run_rows:
if not candidate_refs:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
batch_index += 1
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
candidate_high_water = self._cursor_from_ref(candidate_refs[-1])
last_seen = candidate_high_water
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
"workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms",
batch_index,
len(run_rows),
len(candidate_refs),
int((time.monotonic() - fetch_start) * 1000),
)
tenant_ids = {row.tenant_id for row in run_rows}
tenant_ids = {ref.tenant_id for ref in candidate_refs}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
@ -285,10 +304,28 @@ class WorkflowRunCleanup:
int((time.monotonic() - filter_start) * 1000),
)
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
paid_or_skipped = len(run_rows) - len(free_runs)
target_refs: Sequence[WorkflowRunCleanupRef] = []
if free_tenants:
target_fetch_start = time.monotonic()
target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=candidate_last_seen,
batch_size=self.batch_size,
tenant_ids=sorted(free_tenants),
upper_bound=candidate_high_water,
)
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s target refs in %sms",
batch_index,
len(target_refs),
int((time.monotonic() - target_fetch_start) * 1000),
)
if not free_runs:
target_run_ids = [ref.id for ref in target_refs]
paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0)
if not target_run_ids:
skipped_message = (
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
)
@ -299,7 +336,7 @@ class WorkflowRunCleanup:
)
)
self._metrics.record_batch(
batch_rows=len(run_rows),
batch_rows=len(candidate_refs),
targeted_runs=0,
skipped_runs=paid_or_skipped,
deleted_runs=0,
@ -309,13 +346,13 @@ class WorkflowRunCleanup:
)
continue
total_runs_targeted += len(free_runs)
total_runs_targeted += len(target_run_ids)
if self.dry_run:
count_start = time.monotonic()
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids(
target_run_ids,
count_node_executions=self._count_node_executions_by_run_ids,
count_trigger_logs=self._count_trigger_logs,
)
logger.info(
@ -325,10 +362,10 @@ class WorkflowRunCleanup:
)
if related_totals is not None:
self._accumulate_related_counts(related_totals, batch_counts)
sample_ids = ", ".join(run.id for run in free_runs[:5])
sample_ids = ", ".join(target_run_ids[:5])
click.echo(
click.style(
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
f"[batch #{batch_index}] would delete {len(target_run_ids)} runs "
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
fg="yellow",
)
@ -339,8 +376,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
skipped_runs=paid_or_skipped,
deleted_runs=0,
related_counts={
@ -354,14 +391,14 @@ class WorkflowRunCleanup:
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
counts = self.workflow_run_repo.delete_runs_with_related_by_ids(
target_run_ids,
delete_node_executions=self._delete_node_executions_by_run_ids,
delete_trigger_logs=self._delete_trigger_logs,
)
delete_ms = int((time.monotonic() - delete_start) * 1000)
except Exception:
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0])
raise
total_runs_deleted += counts["runs"]
@ -382,8 +419,8 @@ class WorkflowRunCleanup:
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
batch_rows=len(candidate_refs),
targeted_runs=len(target_run_ids),
skipped_runs=paid_or_skipped,
deleted_runs=counts["runs"],
related_counts={
@ -439,7 +476,7 @@ class WorkflowRunCleanup:
)
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
tenant_id_list = list(tenant_ids)
tenant_id_list = sorted(set(tenant_ids))
if not dify_config.BILLING_ENABLED:
return set(tenant_id_list)
@ -553,15 +590,17 @@ class WorkflowRunCleanup:
totals["pauses"] += batch.get("pauses", 0)
totals["pause_reasons"] += batch.get("pause_reasons", 0)
def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
@staticmethod
def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]:
return ref.created_at, ref.id
def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
)
return repo.count_by_runs(session, run_ids)
def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]:
run_ids = [run.id for run in runs]
def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]:
repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False)
)

View File

@ -0,0 +1,320 @@
"""Integration tests for workflow run cleanup repository queries."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import override
from uuid import uuid4
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from graphon.entities import WorkflowExecution
from graphon.entities.pause_reason import PauseReasonType
from graphon.enums import WorkflowExecutionStatus, WorkflowType
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository):
"""Concrete repository for tests where save() is not under test."""
@override
def save(self, execution: WorkflowExecution) -> None:
return None
@dataclass
class _TestScope:
"""Per-test identifiers for rows created by cleanup repository tests."""
tenant_id: str = field(default_factory=lambda: str(uuid4()))
app_id: str = field(default_factory=lambda: str(uuid4()))
workflow_id: str = field(default_factory=lambda: str(uuid4()))
user_id: str = field(default_factory=lambda: str(uuid4()))
def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository:
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False))
def _create_workflow_run(
session: Session,
scope: _TestScope,
*,
status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED,
created_at: datetime,
tenant_id: str | None = None,
workflow_id: str | None = None,
workflow_type: str = WorkflowType.WORKFLOW,
) -> WorkflowRun:
workflow_run = WorkflowRun(
id=str(uuid4()),
tenant_id=tenant_id or scope.tenant_id,
app_id=scope.app_id,
workflow_id=workflow_id or scope.workflow_id,
type=workflow_type,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
version="draft",
graph="{}",
inputs="{}",
status=status,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=scope.user_id,
created_at=created_at,
)
session.add(workflow_run)
session.commit()
return workflow_run
def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None:
session.add(
WorkflowAppLog(
tenant_id=workflow_run.tenant_id,
app_id=scope.app_id,
workflow_id=workflow_run.workflow_id,
workflow_run_id=workflow_run.id,
created_from=WorkflowAppLogCreatedFrom.SERVICE_API,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=scope.user_id,
)
)
session.commit()
def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause:
pause = WorkflowPause(
workflow_id=workflow_run.workflow_id,
workflow_run_id=workflow_run.id,
state_object_key=f"workflow-state-{uuid4()}.json",
)
pause_reason = WorkflowPauseReason(
pause_id=pause.id,
type_=PauseReasonType.SCHEDULED_PAUSE,
message="scheduled pause",
)
session.add_all([pause, pause_reason])
session.commit()
return pause
class TestGetCleanupRefsBatchByTimeRange:
def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
base = datetime(2024, 1, 1, 12, 0, 0)
_create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1))
cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base)
first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1))
second_target = _create_workflow_run(
db_session_with_containers,
scope,
status=WorkflowExecutionStatus.FAILED,
created_at=base + timedelta(minutes=2),
)
_create_workflow_run(
db_session_with_containers,
scope,
status=WorkflowExecutionStatus.RUNNING,
created_at=base + timedelta(minutes=1),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
tenant_id=str(uuid4()),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
workflow_id=str(uuid4()),
)
_create_workflow_run(
db_session_with_containers,
scope,
created_at=base + timedelta(minutes=1),
workflow_type=WorkflowType.CHAT,
)
_create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3))
refs = repository.get_cleanup_refs_batch_by_time_range(
start_from=base,
end_before=base + timedelta(minutes=4),
last_seen=(cursor_run.created_at, cursor_run.id),
batch_size=10,
run_types=[WorkflowType.WORKFLOW],
tenant_ids=[scope.tenant_id],
workflow_ids=[scope.workflow_id],
upper_bound=(second_target.created_at, second_target.id),
)
assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [
(first_target.id, scope.tenant_id, first_target.created_at),
(second_target.id, scope.tenant_id, second_target.created_at),
]
def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
refs = repository.get_cleanup_refs_batch_by_time_range(
start_from=None,
end_before=datetime(2024, 1, 2),
last_seen=None,
batch_size=10,
run_types=[],
)
assert refs == []
class TestCountRunsWithRelatedByIds:
def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
missing_run_id = str(uuid4())
_add_app_log(db_session_with_containers, scope, workflow_run)
_add_pause_with_reason(db_session_with_containers, workflow_run)
counted_node_run_ids: list[str] = []
counted_trigger_run_ids: list[str] = []
counts = repository.count_runs_with_related_by_ids(
[workflow_run.id, missing_run_id],
count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1),
count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3,
)
assert counted_node_run_ids == [workflow_run.id, missing_run_id]
assert counted_trigger_run_ids == [workflow_run.id, missing_run_id]
assert counts == {
"runs": 1,
"node_executions": 2,
"offloads": 1,
"app_logs": 1,
"trigger_logs": 3,
"pauses": 1,
"pause_reasons": 1,
}
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
counts = repository.count_runs_with_related_by_ids([workflow_run.id])
assert counts == {
"runs": 1,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
class TestDeleteRunsWithRelatedByIds:
def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
_add_app_log(db_session_with_containers, scope, workflow_run)
pause = _add_pause_with_reason(db_session_with_containers, workflow_run)
pause_id = pause.id
deleted_node_run_ids: list[str] = []
deleted_trigger_run_ids: list[str] = []
counts = repository.delete_runs_with_related_by_ids(
[workflow_run.id],
delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1),
delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3,
)
assert deleted_node_run_ids == [workflow_run.id]
assert deleted_trigger_run_ids == [workflow_run.id]
assert counts == {
"runs": 1,
"node_executions": 2,
"offloads": 1,
"app_logs": 1,
"trigger_logs": 3,
"pauses": 1,
"pause_reasons": 1,
}
verification_session = Session(bind=db_session_with_containers.get_bind())
with verification_session:
assert verification_session.get(WorkflowRun, workflow_run.id) is None
assert verification_session.get(WorkflowPause, pause_id) is None
assert (
verification_session.scalar(
select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id)
)
is None
)
assert (
verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id))
is None
)
def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
scope = _TestScope()
workflow_run = _create_workflow_run(
db_session_with_containers,
scope,
created_at=datetime(2024, 1, 1, 12, 0, 0),
)
counts = repository.delete_runs_with_related_by_ids([workflow_run.id])
assert counts == {
"runs": 1,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None:
repository = _repository(db_session_with_containers)
assert repository.count_runs_with_related_by_ids([]) == {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}
assert repository.delete_runs_with_related_by_ids([]) == {
"runs": 0,
"node_executions": 0,
"offloads": 0,
"app_logs": 0,
"trigger_logs": 0,
"pauses": 0,
"pause_reasons": 0,
}

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import logging
from unittest.mock import patch
from uuid import uuid4
import pytest
@ -259,9 +258,8 @@ class TestEndUserServiceGetOrCreateEndUserByType:
assert len(matching_logs) == 1
@patch("services.end_user_service.logger")
def test_get_existing_end_user_matching_type(
self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog
):
"""Test retrieving existing end user with matching type."""
# Arrange
@ -279,17 +277,19 @@ class TestEndUserServiceGetOrCreateEndUserByType:
)
# Act - Request with same type
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.SERVICE_API,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
with caplog.at_level(logging.INFO, logger="services.end_user_service"):
result = EndUserService.get_or_create_end_user_by_type(
type=InvokeFrom.SERVICE_API,
tenant_id=tenant_id,
app_id=app_id,
user_id=user_id,
)
# Assert
assert result.id == existing_user.id
assert result.type == InvokeFrom.SERVICE_API
mock_logger.info.assert_not_called()
# No legacy-upgrade log should be emitted when the existing user's type already matches.
assert [record for record in caplog.records if record.levelno == logging.INFO] == []
def test_create_anonymous_user_with_default_session(
self, db_session_with_containers: Session, factory: TestEndUserServiceFactory

View File

@ -1,5 +1,6 @@
import logging
import uuid
from unittest.mock import ANY, call, patch
from unittest.mock import call, patch
import pytest
from sqlalchemy import delete, func, select
@ -146,10 +147,7 @@ class TestDeleteDraftVariablesBatch:
assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0
@patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data")
@patch("tasks.remove_app_and_related_data_task.logger")
def test_delete_draft_variables_batch_logs_progress(
self, mock_logger, mock_offload_cleanup, db_session_with_containers
):
def test_delete_draft_variables_batch_logs_progress(self, mock_offload_cleanup, db_session_with_containers, caplog):
"""Test that batch deletion logs progress correctly."""
tenant, app = _create_tenant_and_app(db_session_with_containers)
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=10)
@ -163,14 +161,15 @@ class TestDeleteDraftVariablesBatch:
mock_offload_cleanup.return_value = len(file_id_by_index)
result = delete_draft_variables_batch(app.id, 50)
with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"):
result = delete_draft_variables_batch(app.id, 50)
assert result == 30
mock_offload_cleanup.assert_called_once()
_, called_file_ids = mock_offload_cleanup.call_args.args
assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()}
assert mock_logger.info.call_count == 2
mock_logger.info.assert_any_call(ANY)
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
assert len(info_records) == 2
class TestDeleteDraftVariableOffloadData:
@ -204,10 +203,7 @@ class TestDeleteDraftVariableOffloadData:
assert remaining_upload_files_count == 0
@patch("extensions.ext_storage.storage")
@patch("tasks.remove_app_and_related_data_task.logging")
def test_delete_draft_variable_offload_data_storage_failure(
self, mock_logging, mock_storage, db_session_with_containers
):
def test_delete_draft_variable_offload_data_storage_failure(self, mock_storage, db_session_with_containers, caplog):
"""Test handling of storage deletion failures."""
tenant, app = _create_tenant_and_app(db_session_with_containers)
offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=2)
@ -217,11 +213,12 @@ class TestDeleteDraftVariableOffloadData:
mock_storage.delete.side_effect = [Exception("Storage error"), None]
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
with caplog.at_level(logging.ERROR):
with session_factory.create_session() as session, session.begin():
result = _delete_draft_variable_offload_data(session, file_ids)
assert result == 1
mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0])
assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text
remaining_var_files_count = db_session_with_containers.scalar(
select(func.count())

View File

@ -210,6 +210,26 @@ def test_agent_app_list_and_create_use_agent_route(
id="agent-created", role="Created role", active_config_snapshot_id=None
),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"load_published_references_by_agent_id",
lambda _self, **kwargs: {
"agent-list": [
{
"app_id": "workflow-app-id",
"app_name": "RFP Review Flow",
"app_icon_type": "emoji",
"app_icon": "A",
"app_icon_background": "#fff",
"app_mode": "workflow",
"app_updated_at": 1781660000,
"workflow_id": "workflow-1",
"workflow_version": "v1",
"node_ids": ["node-1", "node-2"],
}
]
},
)
monkeypatch.setattr(
roster_controller.FeatureService,
"get_system_features",
@ -226,6 +246,10 @@ def test_agent_app_list_and_create_use_agent_route(
assert listed["data"][0]["app_id"] == "app-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["active_config_is_published"] is False
assert listed["data"][0]["published_reference_count"] == 1
assert listed["data"][0]["published_node_reference_count"] == 2
assert listed["data"][0]["published_references"][0]["app_id"] == "workflow-app-id"
assert listed["data"][0]["published_references"][0]["node_ids"] == ["node-1", "node-2"]
assert "bound_agent_id" not in listed["data"][0]
list_call = cast(dict[str, object], captured["list"])
list_params = cast(Any, list_call["params"])

View File

@ -2,6 +2,7 @@
Unit tests for Service API File Preview endpoint
"""
import logging
import uuid
from unittest.mock import Mock, patch
@ -348,8 +349,7 @@ class TestFilePreviewApi:
assert "Storage error" in str(exc_info.value)
@patch("controllers.service_api.app.file_preview.logger")
def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi):
def test_validate_file_ownership_unexpected_error_logging(self, file_preview_api: FilePreviewApi, caplog):
"""Test that unexpected errors are logged properly"""
file_id = str(uuid.uuid4())
app_id = str(uuid.uuid4())
@ -359,14 +359,18 @@ class TestFilePreviewApi:
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
# Execute and assert exception
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
with pytest.raises(FileAccessDeniedError) as exc_info:
file_preview_api._validate_file_ownership(file_id, app_id)
# Verify error message
assert "File access validation failed" in str(exc_info.value)
# Verify logging was called
mock_logger.exception.assert_called_once_with(
"Unexpected error during file ownership validation",
extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"},
)
# Verify logging was called with the structured context fields. The ``extra`` keys
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.getMessage() == "Unexpected error during file ownership validation"
assert record.file_id == file_id
assert record.app_id == app_id
assert record.error == "Unexpected database error"

View File

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

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_
assert ("x-custom", "foo") in headers
@patch("enterprise.telemetry.exporter.logger")
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
@patch("enterprise.telemetry.exporter.GRPCMetricExporter")
def test_api_key_overrides_conflicting_header(
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock
mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog
) -> None:
"""Test that API key overrides conflicting authorization header and logs warning."""
mock_config = SimpleNamespace(
@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header(
ENTERPRISE_OTLP_API_KEY="test-key",
)
EnterpriseExporter(mock_config)
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
EnterpriseExporter(mock_config)
# Verify Bearer header takes precedence
assert mock_span_exporter.call_args is not None
@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header(
assert ("authorization", "Basic old") not in headers
# Verify warning was logged
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args is not None
warning_message = mock_logger.warning.call_args[0][0]
assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message
assert "authorization" in warning_message
assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text
assert "authorization" in caplog.text
@patch("enterprise.telemetry.exporter.GRPCSpanExporter")
@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None:
assert kwargs["context"] is not None
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None:
def test_export_span_logs_exception_on_error(caplog) -> None:
"""If the span block raises, the exception is logged and context is still cleared."""
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
mock_tracer.start_as_current_span.side_effect = RuntimeError("boom")
exporter.export_span(name="bad.span", attributes={}) # must not raise
with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"):
exporter.export_span(name="bad.span", attributes={}) # must not raise
mock_logger.exception.assert_called_once()
assert "bad.span" in mock_logger.exception.call_args[0][1]
assert "Failed to export span" in caplog.text
assert "bad.span" in caplog.text
@patch("enterprise.telemetry.exporter.logger")
def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None:
def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None:
"""Invalid UUID for trace_correlation_override triggers a warning log."""
exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer()
parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3"
exporter.export_span(
name="link.span",
attributes={},
correlation_id="not-a-valid-uuid",
parent_span_id_source=parent_uid,
)
with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"):
exporter.export_span(
name="link.span",
attributes={},
correlation_id="not-a-valid-uuid",
parent_span_id_source=parent_uid,
)
mock_logger.warning.assert_called()
assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text
# ---------------------------------------------------------------------------

View File

@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch
import pytest
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
run = MagicMock()
run.tenant_id = tenant_id
run.id = run_id
run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
return run
def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None):
return WorkflowRunCleanupRef(
id=run_id,
tenant_id=tenant_id,
created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC),
)
@pytest.fixture
@ -341,28 +342,28 @@ class TestRunDeleteMode:
return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo)
def test_no_rows_stops_immediately(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
c = self._make_cleanup(mock_repo)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
def test_all_paid_skips_delete(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
c = self._make_cleanup(mock_repo)
# billing disabled -> all free; but let's override _filter_free_tenants to return empty
c._filter_free_tenants = MagicMock(return_value=set())
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
def test_runs_deleted_successfully(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.delete_runs_with_related.return_value = {
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
mock_repo.delete_runs_with_related_by_ids.return_value = {
"runs": 1,
"node_executions": 0,
"offloads": 0,
@ -376,12 +377,12 @@ class TestRunDeleteMode:
cfg.BILLING_ENABLED = False
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"):
c.run()
mock_repo.delete_runs_with_related.assert_called_once()
mock_repo.delete_runs_with_related_by_ids.assert_called_once()
def test_delete_exception_reraises(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error")
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]]
mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error")
c = self._make_cleanup(mock_repo)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
@ -389,7 +390,7 @@ class TestRunDeleteMode:
c.run()
def test_summary_with_window_start(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
cfg.BILLING_ENABLED = False
@ -421,9 +422,10 @@ class TestRunDryRunMode:
)
def test_dry_run_no_delete_called(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
mock_repo.count_runs_with_related.return_value = {
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []]
mock_repo.count_runs_with_related_by_ids.return_value = {
"runs": 1,
"node_executions": 2,
"offloads": 0,
"app_logs": 0,
@ -435,11 +437,11 @@ class TestRunDryRunMode:
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.delete_runs_with_related.assert_not_called()
mock_repo.count_runs_with_related.assert_called_once()
mock_repo.delete_runs_with_related_by_ids.assert_not_called()
mock_repo.count_runs_with_related_by_ids.assert_called_once()
def test_dry_run_summary_with_window_start(self, mock_repo):
mock_repo.get_runs_batch_by_time_range.return_value = []
mock_repo.get_cleanup_refs_batch_by_time_range.return_value = []
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0
cfg.BILLING_ENABLED = False
@ -454,14 +456,14 @@ class TestRunDryRunMode:
c.run()
def test_dry_run_all_paid_skips_count(self, mock_repo):
run = make_run("t1")
mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []]
ref = make_ref("t1")
mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []]
c = self._make_dry_cleanup(mock_repo)
c._filter_free_tenants = MagicMock(return_value=set())
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg:
cfg.BILLING_ENABLED = False
c.run()
mock_repo.count_runs_with_related.assert_not_called()
mock_repo.count_runs_with_related_by_ids.assert_not_called()
# ---------------------------------------------------------------------------
@ -492,7 +494,7 @@ class TestTriggerLogMethods:
# ---------------------------------------------------------------------------
# _count_node_executions / _delete_node_executions
# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids
# ---------------------------------------------------------------------------
@ -500,25 +502,23 @@ class TestNodeExecutionMethods:
def test_count_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
runs = [make_run("t1", "r1")]
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.count_by_runs.return_value = (10, 2)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._count_node_executions(session, runs)
result = cleanup._count_node_executions_by_run_ids(session, ["r1"])
assert result == (10, 2)
def test_delete_node_executions(self, cleanup):
session = MagicMock()
session.get_bind.return_value = MagicMock()
runs = [make_run("t1", "r1")]
with patch(
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
) as factory:
repo = factory.create_api_workflow_node_execution_repository.return_value
repo.delete_by_runs.return_value = (5, 1)
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
result = cleanup._delete_node_executions(session, runs)
result = cleanup._delete_node_executions_by_run_ids(session, ["r1"])
assert result == (5, 1)

View File

@ -3,38 +3,27 @@ from typing import Any
import pytest
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
from services.billing_service import SubscriptionPlan
from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
class FakeRun:
def __init__(
self,
run_id: str,
tenant_id: str,
created_at: datetime.datetime,
app_id: str = "app-1",
workflow_id: str = "wf-1",
triggered_from: str = "workflow-run",
) -> None:
self.id = run_id
self.tenant_id = tenant_id
self.app_id = app_id
self.workflow_id = workflow_id
self.triggered_from = triggered_from
self.created_at = created_at
def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef:
return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at)
class FakeRepo:
def __init__(
self,
batches: list[list[FakeRun]],
batches: list[list[WorkflowRunCleanupRef]],
delete_result: dict[str, int] | None = None,
count_result: dict[str, int] | None = None,
) -> None:
self.batches = batches
self.call_idx = 0
self.candidate_call_idx = 0
self.last_candidate_batch: list[WorkflowRunCleanupRef] = []
self.cleanup_ref_calls: list[dict[str, object]] = []
self.deleted: list[list[str]] = []
self.counted: list[list[str]] = []
self.delete_result = delete_result or {
@ -56,7 +45,7 @@ class FakeRepo:
"pause_reasons": 0,
}
def get_runs_batch_by_time_range(
def get_cleanup_refs_batch_by_time_range(
self,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
@ -65,27 +54,50 @@ class FakeRepo:
run_types=None,
tenant_ids=None,
workflow_ids=None,
) -> list[FakeRun]:
if self.call_idx >= len(self.batches):
upper_bound: tuple[datetime.datetime, str] | None = None,
) -> list[WorkflowRunCleanupRef]:
self.cleanup_ref_calls.append(
{
"start_from": start_from,
"end_before": end_before,
"last_seen": last_seen,
"batch_size": batch_size,
"run_types": run_types,
"tenant_ids": tenant_ids,
"workflow_ids": workflow_ids,
"upper_bound": upper_bound,
}
)
if tenant_ids is not None or upper_bound is not None:
refs = self.last_candidate_batch
if tenant_ids is not None:
tenant_id_set = set(tenant_ids)
refs = [ref for ref in refs if ref.tenant_id in tenant_id_set]
if upper_bound is not None:
refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound]
return refs[:batch_size]
if self.candidate_call_idx >= len(self.batches):
return []
batch = self.batches[self.call_idx]
self.call_idx += 1
batch = self.batches[self.candidate_call_idx]
self.candidate_call_idx += 1
self.last_candidate_batch = batch
return batch
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
self.deleted.append([run.id for run in runs])
self.deleted.append(list(run_ids))
result = self.delete_result.copy()
result["runs"] = len(runs)
result["runs"] = len(run_ids)
return result
def count_runs_with_related(
self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None
def count_runs_with_related_by_ids(
self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None
) -> dict[str, int]:
self.counted.append([run.id for run in runs])
self.counted.append(list(run_ids))
result = self.count_result.copy()
result["runs"] = len(runs)
result["runs"] = len(run_ids)
return result
@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
repo = FakeRepo(
batches=[
[
FakeRun("run-free", "t_free", cutoff),
FakeRun("run-paid", "t_paid", cutoff),
make_ref("run-free", "t_free", cutoff),
make_ref("run-paid", "t_paid", cutoff),
]
]
)
@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
cleanup.run()
assert repo.deleted == [["run-free"]]
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[
[
make_ref("run-free", "t_free", cutoff),
make_ref("run-paid", "t_paid", cutoff),
]
]
)
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
billing_calls: list[list[str]] = []
def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]:
billing_calls.append(tenant_ids)
return {
"t_free": plan_info("sandbox", -1),
"t_paid": plan_info("team", -1),
}
monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk))
cleanup.run()
assert billing_calls == [["t_free", "t_paid"]]
assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"]
assert repo.deleted == [["run-free"]]
def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]])
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None
cleanup.run()
assert repo.deleted == []
assert len(repo.cleanup_ref_calls) == 2
def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True)
monkeypatch.setattr(
cleanup_module.BillingService,
"get_plan_bulk_with_cache",
staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}),
)
batch_calls: list[dict[str, object]] = []
monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs))
cleanup.run()
assert repo.deleted == []
assert repo.counted == []
assert batch_calls[0]["batch_rows"] == 1
assert batch_calls[0]["targeted_runs"] == 0
assert batch_calls[0]["skipped_runs"] == 1
assert batch_calls[0]["deleted_runs"] == 0
def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None:
first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0)
second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0)
repo = FakeRepo(
batches=[
[
make_ref("run-free-1", "t_free", first_created_at),
make_ref("run-free-2", "t_free", second_created_at),
]
]
)
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
cleanup.run()
assert repo.cleanup_ref_calls[1]["last_seen"] is None
assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2")
assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2")
def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[[FakeRun("run-free", "t_free", cutoff)]],
batches=[[make_ref("run-free", "t_free", cutoff)]],
delete_result={
"runs": 0,
"node_executions": 2,
@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None
def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingRepo(FakeRepo):
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
def delete_runs_with_related_by_ids(
self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
raise RuntimeError("delete failed")
cutoff = datetime.datetime.now()
repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]])
repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[[FakeRun("run-free", "t_free", cutoff)]],
batches=[[make_ref("run-free", "t_free", cutoff)]],
count_result={
"runs": 0,
"node_executions": 2,

View File

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

View File

@ -4,8 +4,8 @@ export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type AppPagination = {
data: Array<AppPartial>
export type AgentAppPagination = {
data: Array<AgentAppPartial>
has_more: boolean
limit: number
page: number
@ -258,7 +258,7 @@ export type AgentConfigSnapshotDetailResponse = {
version_note?: string | null
}
export type AppPartial = {
export type AgentAppPartial = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
@ -279,6 +279,9 @@ export type AppPartial = {
mode: string
model_config?: ModelConfigPartial | null
name: string
published_node_reference_count?: number
published_reference_count?: number
published_references?: Array<AgentPublishedReferenceResponse>
role?: string | null
tags?: Array<Tag>
updated_at?: number | null
@ -665,12 +668,6 @@ export type ModelConfigPartial = {
updated_by?: string | null
}
export type LlmMode = 'chat' | 'completion'
export type AgentKind = 'dify_agent'
export type AgentIconType = 'emoji' | 'image' | 'link'
export type AgentPublishedReferenceResponse = {
app_icon?: string | null
app_icon_background?: string | null
@ -684,6 +681,12 @@ export type AgentPublishedReferenceResponse = {
workflow_version: string
}
export type LlmMode = 'chat' | 'completion'
export type AgentKind = 'dify_agent'
export type AgentIconType = 'emoji' | 'image' | 'link'
export type AgentScope = 'roster' | 'workflow_only'
export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow'
@ -1258,8 +1261,8 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type AppPaginationWritable = {
data: Array<AppPartialWritable>
export type AgentAppPaginationWritable = {
data: Array<AgentAppPartialWritable>
has_more: boolean
limit: number
page: number
@ -1296,7 +1299,7 @@ export type AppDetailWithSiteWritable = {
workflow?: WorkflowPartial | null
}
export type AppPartialWritable = {
export type AgentAppPartialWritable = {
access_mode?: string | null
active_config_is_published?: boolean
app_id?: string | null
@ -1316,6 +1319,9 @@ export type AppPartialWritable = {
mode: string
model_config?: ModelConfigPartial | null
name: string
published_node_reference_count?: number
published_reference_count?: number
published_references?: Array<AgentPublishedReferenceResponse>
role?: string | null
tags?: Array<Tag>
updated_at?: number | null
@ -1365,7 +1371,7 @@ export type GetAgentData = {
}
export type GetAgentResponses = {
200: AppPagination
200: AgentAppPagination
}
export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses]

View File

@ -471,9 +471,25 @@ export const zModelConfigPartial = z.object({
})
/**
* AppPartial
* AgentPublishedReferenceResponse
*/
export const zAppPartial = z.object({
export const zAgentPublishedReferenceResponse = z.object({
app_icon: z.string().nullish(),
app_icon_background: z.string().nullish(),
app_icon_type: z.string().nullish(),
app_id: z.string(),
app_mode: z.string(),
app_name: z.string(),
app_updated_at: z.int().nullish(),
node_ids: z.array(z.string()).optional(),
workflow_id: z.string(),
workflow_version: z.string(),
})
/**
* AgentAppPartial
*/
export const zAgentAppPartial = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
@ -494,6 +510,9 @@ export const zAppPartial = z.object({
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
name: z.string(),
published_node_reference_count: z.int().optional().default(0),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
@ -503,10 +522,10 @@ export const zAppPartial = z.object({
})
/**
* AppPagination
* AgentAppPagination
*/
export const zAppPagination = z.object({
data: z.array(zAppPartial),
export const zAgentAppPagination = z.object({
data: z.array(zAgentAppPartial),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
@ -581,22 +600,6 @@ export const zAgentKind = z.enum(['dify_agent'])
*/
export const zAgentIconType = z.enum(['emoji', 'image', 'link'])
/**
* AgentPublishedReferenceResponse
*/
export const zAgentPublishedReferenceResponse = z.object({
app_icon: z.string().nullish(),
app_icon_background: z.string().nullish(),
app_icon_type: z.string().nullish(),
app_id: z.string(),
app_mode: z.string(),
app_name: z.string(),
app_updated_at: z.int().nullish(),
node_ids: z.array(z.string()).optional(),
workflow_id: z.string(),
workflow_version: z.string(),
})
/**
* AgentScope
*
@ -1773,9 +1776,9 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
})
/**
* AppPartial
* AgentAppPartial
*/
export const zAppPartialWritable = z.object({
export const zAgentAppPartialWritable = z.object({
access_mode: z.string().nullish(),
active_config_is_published: z.boolean().optional().default(false),
app_id: z.string().nullish(),
@ -1795,6 +1798,9 @@ export const zAppPartialWritable = z.object({
mode: z.string(),
model_config: zModelConfigPartial.nullish(),
name: z.string(),
published_node_reference_count: z.int().optional().default(0),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
role: z.string().nullish(),
tags: z.array(zTag).optional(),
updated_at: z.int().nullish(),
@ -1804,10 +1810,10 @@ export const zAppPartialWritable = z.object({
})
/**
* AppPagination
* AgentAppPagination
*/
export const zAppPaginationWritable = z.object({
data: z.array(zAppPartialWritable),
export const zAgentAppPaginationWritable = z.object({
data: z.array(zAgentAppPartialWritable),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
@ -1895,7 +1901,7 @@ export const zGetAgentQuery = z.object({
/**
* Agent app list
*/
export const zGetAgentResponse = zAppPagination
export const zGetAgentResponse = zAgentAppPagination
export const zPostAgentBody = zAgentAppCreatePayload

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?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${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/cloud/use-dify/knowledge/connect-external-knowledge-base')
expect(docLink)!.toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base')
expect(docLink)!.toHaveAttribute('target', '_blank')
expect(docLink)!.toHaveAttribute('rel', 'noopener noreferrer')
})

View File

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

View File

@ -2,20 +2,13 @@ import type { SlashCommandHandler } from './types'
import { RiBookOpenLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
import { defaultDocBaseUrl } 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
*/
@ -26,7 +19,11 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
// Direct execution function
execute: () => {
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
const i18n = getI18n()
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
@ -46,9 +43,14 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
},
register(_deps: DocDeps) {
const i18n = getI18n()
registerCommands({
'navigation.doc': async (_args) => {
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
// Get the current language from i18n
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_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 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')
// 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')
})
})
})

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/self-host/use-dify/workspace/tools')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/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/self-host/use-dify/build/mcp')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/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/self-host/use-dify/workspace/tools#custom-tool')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/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/self-host/use-dify/workspace/api-extension/api-extension')
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.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/self-host/use-dify/workspace/tools#workflow-tool')
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/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('/use-dify/') ? `/cloud${path}` : path || ''}`,
useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : 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/cloud/use-dify/workspace/tools#custom-tool')
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/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/self-host/use-dify/workspace/tools#workflow-tool')
expect(screen.getByRole('link', { name: /tools\.workflowToolEmpty\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/tools#workflow-tool')
})
})

View File

@ -47,16 +47,4 @@ 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,20 +12,8 @@ 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()
@ -61,14 +49,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 = getNodeHelpLinkPath(metaData.helpLinkUri)
const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
return {
...node,
metaData: {
...metaData,
title,
description,
helpLinkUri: helpLinkPath ? docLink(helpLinkPath) : undefined,
helpLinkUri: docLink(helpLinkPath),
},
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/self-host/use-dify/nodes/trigger/overview',
'https://docs.dify.ai/en/use-dify/nodes/trigger/overview',
)
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
})

View File

@ -6,7 +6,6 @@ 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,10 +5,6 @@ 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(() => ({
@ -16,12 +12,6 @@ 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> = {
@ -38,7 +28,6 @@ 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>)
@ -56,28 +45,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/home`)
expect(url).toBe(`${defaultDocBaseUrl}/en`)
})
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/home`)
expect(url).toBe(`${customBaseUrl}/en`)
})
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/cloud/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/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/cloud/use-dify/getting-started/introduction')
expect(url).toBe('https://docs.dify.ai/en/use-dify/getting-started/introduction')
})
})
@ -85,31 +74,19 @@ 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/cloud/use-dify/getting-started/introduction`)
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
})
it('should handle empty path', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current()
expect(url).toBe(`${defaultDocBaseUrl}/en/home`)
expect(url).toBe(`${defaultDocBaseUrl}/en`)
})
it('should handle undefined path', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current(undefined)
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`)
expect(url).toBe(`${defaultDocBaseUrl}/en`)
})
})
@ -122,12 +99,12 @@ describe('useDocLink', () => {
const pathMap: DocPathMap = {
'zh-Hans': '/use-dify/getting-started/introduction',
'en-US': '/use-dify/build/mcp',
'en-US': '/use-dify/getting-started/quick-start',
}
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/build/mcp', pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
})
it('should use default path when locale not in pathMap', () => {
@ -138,76 +115,18 @@ describe('useDocLink', () => {
const pathMap: DocPathMap = {
'zh-Hans': '/use-dify/getting-started/introduction',
'en-US': '/use-dify/build/mcp',
'en-US': '/use-dify/getting-started/quick-start',
}
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/build/mcp', pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/ja/cloud/use-dify/build/mcp`)
const url = result.current('/use-dify/getting-started/quick-start' as DocPathWithoutLang, pathMap)
expect(url).toBe(`${defaultDocBaseUrl}/ja/use-dify/getting-started/quick-start`)
})
it('should handle undefined pathMap', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction', undefined)
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`)
expect(url).toBe(`${defaultDocBaseUrl}/en/use-dify/getting-started/introduction`)
})
})
@ -313,7 +232,7 @@ describe('useDocLink', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/use-dify/getting-started/introduction')
expect(url).toBe(`${defaultDocBaseUrl}/zh/cloud/use-dify/getting-started/introduction`)
expect(url).toBe(`${defaultDocBaseUrl}/zh/use-dify/getting-started/introduction`)
})
})
@ -321,15 +240,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/cloud/use-dify/getting-started/introduction#overview`)
expect(url).toBe(`${defaultDocBaseUrl}/en/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/build/mcp')
expect(url1).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/getting-started/introduction`)
expect(url2).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/build/mcp`)
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`)
})
})
})

View File

@ -1,10 +1,9 @@
import type { Locale } from '@/i18n-config/language'
import type { DocPathWithoutLang, DocsProduct } from '@/types/doc-paths'
import type { DocPathWithoutLang } 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, docPathProductAvailability } from '@/types/doc-paths'
import { apiReferencePathTranslations } from '@/types/doc-paths'
export const useLocale = () => {
const { i18n } = useTranslation()
@ -25,44 +24,6 @@ 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
@ -83,12 +44,6 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
}
}
}
else if (!targetPath) {
targetPath = getDocHomePath()
}
else {
targetPath = getProductAwarePath(targetPath)
}
return `${baseDocUrl}${languagePrefix}${targetPath}`
},

View File

@ -11,8 +11,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
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 DOCS_JSON_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json'
const OUTPUT_PATH = path.resolve(__dirname, '../types/doc-paths.ts')
type NavItem = string | NavObject | NavItem[]
@ -22,9 +21,6 @@ type NavObject = {
groups?: NavItem[]
dropdowns?: NavItem[]
languages?: NavItem[]
products?: NavItem[]
tabs?: NavItem[]
menu?: NavItem[]
versions?: NavItem[]
openapi?: string
[key: string]: unknown
@ -62,15 +58,7 @@ type DocsJson = {
[key: string]: unknown
}
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)
}
const OPENAPI_BASE_URL = 'https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/'
/**
* Convert summary to URL slug
@ -124,15 +112,6 @@ 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)
}
@ -220,15 +199,6 @@ 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)
@ -237,68 +207,33 @@ 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>): { groups: Record<string, Set<string>>, productAvailability: ProductAvailability } {
function groupPathsBySection(paths: Set<string>): Record<string, Set<string>> {
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
// Skip non-doc paths (like .json files for OpenAPI)
if (withoutLang.endsWith('.json') || withoutLang === 'None')
continue
// Get section (first part of path)
const parts = withoutLang.split('/')
const section = parts[0]
addPathToGroup(groups, withoutLang)
if (!groups[section!])
groups[section!] = new Set()
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)
}
groups[section!]!.add(withoutLang)
}
return {
groups,
productAvailability,
}
return groups
}
/**
@ -316,7 +251,6 @@ function sectionToTypeName(section: string): string {
*/
function generateTypeDefinitions(
groups: Record<string, Set<string>>,
productAvailability: ProductAvailability,
apiReferencePaths: string[],
apiPathTranslations: Record<string, { zh?: string, ja?: string }>,
): string {
@ -324,12 +258,11 @@ function generateTypeDefinitions(
'// GENERATE BY script',
'// DON NOT EDIT IT MANUALLY',
'//',
`// Generated from: ${DOCS_JSON_URL}`,
'// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json',
`// Generated at: ${new Date().toISOString()}`,
'',
'// Language prefixes',
'export type DocLanguage = \'en\' | \'zh\' | \'ja\'',
'export type DocsProduct = \'cloud\' | \'self-host\'',
'',
]
@ -388,14 +321,10 @@ function generateTypeDefinitions(
lines.push(' | `${DocPathWithoutLangBase}#${string}`')
lines.push('')
// 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('}')
// 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}`')
lines.push('')
// Generate API reference path translations map
@ -494,13 +423,12 @@ async function main(): Promise<void> {
console.log(`Generated ${Object.keys(apiPathTranslations).length} API path translations`)
// Group by section
const { groups, productAvailability } = groupPathsBySection(allPaths)
const groups = 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, productAvailability, uniqueEnApiPaths, apiPathTranslations)
const tsContent = generateTypeDefinitions(groups, uniqueEnApiPaths, apiPathTranslations)
// Write to file
await writeFile(OUTPUT_PATH, tsContent, 'utf-8')

View File

@ -1,127 +1,28 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
//
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/feat/audience-products/docs.json
// Generated at: 2026-06-17T04:42:51.293Z
// Generated from: https://raw.githubusercontent.com/langgenius/dify-docs/refs/heads/main/docs.json
// Generated at: 2026-03-25T03:18:49.626Z
// 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/agent'
| '/use-dify/build/chatbot'
| '/use-dify/build/goto-anything'
| '/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'
@ -185,8 +86,24 @@ 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'
@ -198,42 +115,25 @@ 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>
// 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'
// 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'
// DevelopPlugin paths
type DevelopPluginPath =
@ -265,7 +165,6 @@ 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'
@ -278,129 +177,6 @@ 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'
@ -413,12 +189,6 @@ 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'
@ -455,8 +225,6 @@ 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'
@ -484,24 +252,19 @@ 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
| HomePath
| LearnPath
| QuickStartPath
| CliPath
| DevelopPluginPath
| SelfHostPath
| DeployPath
| DevelopPluginPath
| ApiReferencePath
// Combined path without language prefix (supports optional #anchor)
@ -509,116 +272,6 @@ 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/アノテーション管理/アノテーション返信を設定' },
@ -631,12 +284,6 @@ 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/チャットメッセージ/生成を停止' },
@ -673,8 +320,6 @@ 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/データセット/ナレッジベース詳細を取得' },
@ -702,10 +347,11 @@ 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/ワークフロー/ワークフローイベントをストリーム' },
}