Merge remote-tracking branch 'origin/main' into deploy/konwledge

# Conflicts:
#	api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py
#	api/tests/unit_tests/services/test_async_workflow_service.py
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
This commit is contained in:
Stephen Zhou 2026-08-21 10:39:39 +08:00
commit e6fe2cd5d7
No known key found for this signature in database
412 changed files with 15369 additions and 6392 deletions

4
.github/CODEOWNERS vendored
View File

@ -267,5 +267,9 @@
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
/web/service/server.ts @iamjoel @lyzno1
# Frontend - Browser Mode Tests
/web/app/**/*.browser.spec.ts @lyzno1
/web/app/**/*.browser.spec.tsx @lyzno1
# Docker
/docker/* @laipz8200

View File

@ -36,7 +36,7 @@ concurrency:
jobs:
accessibility:
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:

View File

@ -14,7 +14,7 @@ permissions:
jobs:
test:
name: Web Full-Stack E2E
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:

View File

@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@ -46,7 +46,7 @@ jobs:
browser-test:
name: Web Browser Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
@ -67,7 +67,17 @@ jobs:
run: vp exec playwright install --with-deps --only-shell chromium
- name: Run browser tests
run: vp test run --config vitest.browser.config.ts --silent=passed-only
run: vp test run --project browser --silent=passed-only
- name: Upload browser failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: browser-test-failures
path: web/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7
merge-reports:
name: Merge Test Reports
@ -111,7 +121,7 @@ jobs:
dify-ui-test:
name: dify-ui Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@ -136,6 +146,16 @@ jobs:
- name: Run dify-ui tests
run: vp test run --project unit --coverage --silent=passed-only
- name: Upload dify-ui test failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dify-ui-test-failures
path: packages/dify-ui/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
@ -147,7 +167,7 @@ jobs:
dify-ui-storybook-test:
name: dify-ui Storybook Tests
runs-on: depot-ubuntu-24.04-4
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
@ -169,3 +189,13 @@ jobs:
- name: Run dify-ui Storybook tests
run: vp run test:storybook
- name: Upload dify-ui Storybook test failure artifacts
if: ${{ failure() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dify-ui-storybook-test-failures
path: packages/dify-ui/.vitest-browser/
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7

1
.gitignore vendored
View File

@ -268,6 +268,7 @@ scripts/stress-test/reports/
.qoder/*
.context/
# Vitest local reports
web/.vitest-browser/
web/.vitest-reports/
# dify-agent-runtime

View File

@ -709,6 +709,8 @@ AGENT_BACKEND_BASE_URL=http://localhost:5050
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
# KnowledgeFS (Dataset 2.0)
KNOWLEDGE_FS_ENABLED=false

View File

@ -93,6 +93,24 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:account-application-boundary]
name = Account application services and contracts are framework and persistence neutral
type = forbidden
source_modules =
services.account_errors
services.account_ports
services.account_profile_service
services.entities.account_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:app-definition-query-service-boundary]
name = App definition query application service is framework and persistence neutral
type = forbidden
@ -124,6 +142,24 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:web-app-runtime-query-service-boundary]
name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules
type = forbidden
source_modules =
services.web_app_runtime_query_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.feature_service
services.feature_service_gateway
sqlalchemy
werkzeug
allow_indirect_imports = True
[importlinter:contract:feature-query-service-boundary]
name = Feature query application service is framework and persistence neutral
type = forbidden
@ -200,3 +236,43 @@ forbidden_modules =
repositories
sqlalchemy
werkzeug
[importlinter:contract:tag-application-service-boundary]
name = Tag application service is framework and persistence neutral
type = forbidden
source_modules =
services.tag_application_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-query-service-boundary]
name = Recommended app query application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_query_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
services.feature_service
services.recommended_app_catalog_gateway
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
source_modules =
services.recommended_app_catalog_gateway
forbidden_modules =
flask

View File

@ -8,10 +8,21 @@ from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackend
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
def create_agent_backend_client(*, base_url: str, api_token: str | None = None, stream_timeout: float = 30) -> Client:
def create_agent_backend_client(
*,
base_url: str,
api_token: str | None = None,
stream_timeout: float = 30,
binding_file_download_timeout: float = 240,
) -> Client:
api_token = api_token.strip() if api_token else None
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
return Client(base_url=base_url, stream_timeout=stream_timeout, headers=headers)
return Client(
base_url=base_url,
stream_timeout=stream_timeout,
binding_file_download_timeout=binding_file_download_timeout,
headers=headers,
)
def create_agent_backend_run_client(

View File

@ -1,6 +1,7 @@
from configs.extra.agent_backend_config import AgentBackendConfig
from configs.extra.archive_config import ArchiveStorageConfig
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
from configs.extra.logstore_config import LogStoreConfig
from configs.extra.notion_config import NotionConfig
from configs.extra.sentry_config import SentryConfig
from configs.extra.turnstile_config import TurnstileConfig
@ -11,6 +12,7 @@ class ExtraServiceConfig(
AgentBackendConfig,
ArchiveStorageConfig,
KnowledgeFSConfig,
LogStoreConfig,
NotionConfig,
SentryConfig,
TurnstileConfig,

View File

@ -37,6 +37,11 @@ class AgentBackendConfig(BaseSettings):
default=3,
)
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS: PositiveFloat = Field(
description="Client timeout for converting a Binding file to a ToolFile through the Agent backend.",
default=240,
)
AGENT_SHELL_ENABLED: bool = Field(
description=(
"Inject the Home, Workspace, Sandbox, and Shell runtime layers into Agent runs. "

View File

@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class LogStoreConfig(BaseSettings):
"""Migration controls for repositories backed by Aliyun LogStore."""
LOGSTORE_DUAL_WRITE_ENABLED: bool = False
# Keep workflow graphs in LogStore by default. Deployments may disable this
# while migrating large graph payloads to another persistence owner.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: bool = True

View File

@ -36,6 +36,7 @@ _UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
"pptx",
"xml",
"epub",
"odt",
)
)
_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
@ -53,6 +54,7 @@ _DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset(
"csv",
"vtt",
"properties",
"odt",
)
)

View File

@ -166,6 +166,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
"is_learn_dify": true,
"is_listed": true,
"position": 5,
"privacy_policy": null
@ -294,6 +295,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
"is_learn_dify": true,
"is_listed": true,
"position": 4,
"privacy_policy": null
@ -326,6 +328,7 @@
"categories": ["Workflow"],
"copyright": null,
"description": "Basic Workflow Template, A chatbot with a knowledge base. ",
"is_learn_dify": true,
"is_listed": true,
"position": 4,
"privacy_policy": null

View File

@ -20,6 +20,8 @@ from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from configs import dify_config
from controllers.common.app_access import resolve_app_access_filter
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
@ -53,6 +55,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.file_access import DatabaseFileAccessController
from core.db.session_factory import session_factory
from core.helper import encrypter
from core.helper.trace_id_helper import get_external_trace_id
from core.plugin.impl.exc import PluginInvokeError
@ -95,7 +98,6 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
from services.errors.llm import InvokeRateLimitError
from services.workflow_ref_service import WorkflowRefService
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -1303,7 +1305,7 @@ class PublishedWorkflowApi(Resource):
workflow_service = WorkflowService()
with sessionmaker(db.engine).begin() as session:
workflow, retirement_candidates = workflow_service.publish_workflow(
workflow = workflow_service.publish_workflow(
session=session,
app_model=app_model,
account=current_user,
@ -1320,16 +1322,6 @@ class PublishedWorkflowApi(Resource):
workflow_created_at = TimestampField().format(workflow.created_at)
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return {
"result": "success",
"created_at": workflow_created_at,
@ -1620,10 +1612,11 @@ class WorkflowByIdApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@console_ns.response(204, "Workflow deleted successfully")
def delete(self, app_model: App, workflow_id: str):
def delete(self, current_user: Account, app_model: App, workflow_id: str):
"""
Delete workflow
"""
@ -1633,7 +1626,7 @@ class WorkflowByIdApi(Resource):
# Create a session and manage the transaction
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
retirement_candidates = workflow_service.delete_workflow(
session=session,
workflow_ref=workflow_ref,
)
@ -1644,6 +1637,11 @@ class WorkflowByIdApi(Resource):
except ValueError as e:
raise NotFound(str(e))
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
return None, 204
@ -1929,8 +1927,9 @@ class WorkflowOnlineUsersApi(Resource):
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str):
def post(self, current_tenant_id: str, current_user: Account):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
app_ids = args.app_ids
@ -1940,8 +1939,20 @@ class WorkflowOnlineUsersApi(Resource):
if not app_ids:
return {"data": []}
access_filter = None
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session())
with session_factory.create_session() as session:
if dify_config.RBAC_ENABLED:
access_filter = resolve_app_access_filter(current_tenant_id, current_user.id, session=session)
app_maintainers = workflow_service.get_tenant_app_maintainers(app_ids, current_tenant_id, session=session)
accessible_app_ids = set(app_maintainers)
if access_filter is not None:
accessible_app_ids = {
app_id
for app_id, maintainer in app_maintainers.items()
if access_filter.is_app_accessible(app_id, maintainer, current_user.id)
}
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
users_json_by_app_id: dict[str, Any] = {}

View File

@ -1,8 +1,9 @@
"""Controller decorators for console app resources.
`get_app_model` still supports legacy handlers backed by Flask-SQLAlchemy's
scoped session. Trial app handlers compose `get_app_model_with_trial` under
`controllers.common.session.with_session` and always reuse that request session.
scoped session. Preview handlers compose `get_previewable_app_model` under
`controllers.common.session.with_session`; preview admission finishes before
the request Session loads the accepted App.
"""
from collections.abc import Callable
@ -16,16 +17,17 @@ from configs import dify_config
from controllers.common.session import with_session
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode, TrialApp
from models import App, AppMode
from models.agent import AgentScope
from services.recommended_app_service import RecommendedAppService
from services.app_service import AppService
__all__ = [
"agent_manage_required_for_agent_app",
"get_app_model",
"get_app_model_with_trial",
"get_previewable_app_model",
"with_session",
]
@ -48,12 +50,11 @@ def _load_app_model_from_scoped_session(app_id: str) -> App | None:
return app_model
def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
"""Load a normal app through its trial registration without applying current-tenant scope."""
app_model = session.scalar(
select(App).join(TrialApp, TrialApp.app_id == App.id).where(App.id == app_id, App.status == "normal").limit(1)
)
return app_model
def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
"""Load a normal App after preview admission completes outside the request Session."""
if not application_services().recommended_app_queries.is_previewable(app_id):
return None
return AppService.get_normal_app_by_id(app_id, session)
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@ -183,7 +184,7 @@ def get_app_model[**P, R](
@overload
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: Callable[P, R],
*,
mode: AppMode | list[AppMode] | None = None,
@ -191,19 +192,25 @@ def get_app_model_with_trial[**P, R](
@overload
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def get_app_model_with_trial[**P, R](
def get_previewable_app_model[**P, R](
view: Callable[P, R] | None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
"""Inject a trial-registered or recommended App using the Session supplied by `with_session`."""
"""Inject an App authorized for read-only template preview.
Preview reads accept either an explicit TrialApp registration or membership
in the recommended catalog. This does not grant trial execution, which is
separately protected by TrialAppResource's feature, registration, and quota
checks.
"""
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
@ -218,10 +225,8 @@ def get_app_model_with_trial[**P, R](
session = _get_injected_session(args)
if session is None:
raise RuntimeError("get_app_model_with_trial requires @with_session")
app_model = _load_app_model_with_trial(session, app_id)
if app_model is None:
app_model = RecommendedAppService.get_app(app_id, session=session)
raise RuntimeError("get_previewable_app_model requires @with_session")
app_model = _load_previewable_app_model(session, app_id)
if not app_model:
raise AppNotFoundError()

View File

@ -6,11 +6,14 @@ from constants.languages import supported_language
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
from extensions.ext_application_services import application_services
from libs.helper import EmailStr, dump_response, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
@ -141,6 +144,8 @@ class ActivateApi(Resource):
raise AlreadyActivateError() from None
except InvitationAccountMismatchError:
raise InvitationAccountMismatchHTTPError() from None
except EmailDomainSuspendedRegistrationError:
raise EmailDomainSuspendedError() from None
except FrozenAccountError:
raise AccountInFreezeError() from None

View File

@ -24,9 +24,15 @@ from libs.password import valid_password
from models import Account
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
@ -99,10 +105,12 @@ class EmailRegisterSendEmailApi(Resource):
if req_data.language is not None and req_data.language in languages:
language = req_data.language
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
@ -217,5 +225,7 @@ class EmailRegisterResetApi(Resource):
)
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc

View File

@ -34,6 +34,7 @@ from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
SeatsLimitExceeded,
@ -74,6 +75,9 @@ from services.errors.account import (
RefreshTokenNotFoundError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.turnstile_service import (
@ -149,11 +153,13 @@ class LoginApi(Resource):
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
if is_login_error_rate_limit:
@ -255,8 +261,10 @@ class ResetPasswordSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
token = AccountService.send_reset_password_email(
email=normalized_email,
@ -297,8 +305,10 @@ class EmailCodeLoginSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
if account is None:
if FeatureService.get_system_features().is_allow_register:
@ -377,9 +387,12 @@ class EmailCodeLoginApi(Resource):
except Unauthorized as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
raise AccountBannedError() from exc
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
@ -405,9 +418,12 @@ class EmailCodeLoginApi(Resource):
raise NotAllowedCreateWorkspace()
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)

View File

@ -12,6 +12,7 @@ from configs import dify_config
from constants.languages import languages
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
@ -26,7 +27,14 @@ from libs.token import (
from models import Account, AccountStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService
from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountNotFoundError,
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService
@ -249,8 +257,10 @@ class OAuthCallback(Resource):
)
except SeatsLimitExceededError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.")
except AccountRegisterError as e:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
except EmailDomainSuspendedRegistrationError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={EmailDomainSuspendedError.description}")
except AccountRegisterError as exc:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={exc.description}")
# Check account status
if account.status == AccountStatus.BANNED:
@ -309,15 +319,12 @@ def _generate_account(
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedRegistrationError()
raise AccountRegisterError(description=AccountInFreezeError.description or "")
raise AccountRegisterError(description=("Invalid email or password"))
account_name = user_info.name or "Dify"
interface_language = _preferred_interface_language(language)

View File

@ -732,10 +732,12 @@ class DatasetApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str, session)
if dataset is None:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
if not dify_config.RBAC_ENABLED:
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
current_tenant_id,
current_user.id,

View File

@ -631,12 +631,16 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
):
# check dataset
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str, session)
dataset = DatasetService.get_dataset_for_tenant(dataset_id_str, current_tenant_id, session=session)
if not dataset:
raise NotFound("Dataset not found.")
# check document
document_id_str = str(document_id)
document = DocumentService.get_document(dataset_id_str, document_id_str, session=session)
document_ref = DatasetRefService.create_document_ref_from_id(
DatasetRefService.create_dataset_ref(dataset), document_id_str
)
document = DatasetRefService.get_document_by_ref(document_ref, session=session)
if not document:
raise NotFound("Document not found.")

View File

@ -336,6 +336,8 @@ class DatasourceAuth(Resource):
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):

View File

@ -61,6 +61,7 @@ from models import Account
from models.dataset import Pipeline
from models.model import EndUser
from models.workflow import Workflow
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.dataset_service import DatasetService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@ -770,8 +771,9 @@ class RagPipelineByIdApi(Resource):
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@with_current_user
@get_rag_pipeline
def delete(self, pipeline: Pipeline, workflow_id: str):
def delete(self, current_user: Account, pipeline: Pipeline, workflow_id: str):
"""
Delete a published workflow version that is not currently active on the pipeline.
"""
@ -783,7 +785,7 @@ class RagPipelineByIdApi(Resource):
with sessionmaker(db.engine).begin() as session:
try:
workflow_service.delete_workflow(
retirement_candidates = workflow_service.delete_workflow(
session=session,
workflow_ref=workflow_ref,
)
@ -794,6 +796,11 @@ class RagPipelineByIdApi(Resource):
except ValueError as e:
raise NotFound(str(e))
WorkflowAgentRetirementService.retire_unowned(
tenant_id=pipeline.tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
return None, 204

View File

@ -92,11 +92,17 @@ class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
code = 400
description = (
"This email account has been deleted within the past 30 days"
"This email account has been deleted within the past 30 days "
"and is temporarily unavailable for new account registration."
)
class EmailDomainSuspendedError(BaseHTTPException):
error_code = "email_domain_suspended"
code = 400
description = "This email domain has been suspended."
class EducationVerifyLimitError(BaseHTTPException):
error_code = "education_verify_limit"
description = "Rate limit exceeded"

View File

@ -31,6 +31,12 @@ class AppAccessDeniedError(BaseHTTPException):
code = 403
class RecommendedAppNotFoundError(BaseHTTPException):
error_code = "recommended_app_not_found"
description = "Recommended app not found."
code = 404
class TrialAppNotAllowed(BaseHTTPException):
"""*403* `Trial App Not Allowed`
@ -51,3 +57,9 @@ class TrialAppLimitExceeded(BaseHTTPException):
error_code = "trial_app_limit_exceeded"
code = 403
description = "The user has exceeded the trial app limit."
class TrialAppFeatureDisabledError(BaseHTTPException):
error_code = "trial_app_feature_disabled"
code = 403
description = "Trial app feature is not enabled."

View File

@ -2,18 +2,18 @@ from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
from pydantic import BaseModel, Field, computed_field, field_validator
from constants.languages import languages
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.wraps import account_initialization_required, model_validate, with_current_user
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import build_icon_url, dump_response
from libs.login import login_required
from models import Account
from services.recommended_app_service import RecommendedAppService
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
class RecommendedAppsQuery(BaseModel):
@ -79,10 +79,6 @@ class RecommendedAppDetailResponse(ResponseModel):
can_trial: bool
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
pass
register_schema_models(
console_ns,
RecommendedAppsQuery,
@ -94,18 +90,9 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
RecommendedAppDetailNullableResponse,
)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@ -115,12 +102,12 @@ class RecommendedAppListApi(Resource):
@with_current_user
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
# language args
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
RecommendedAppListResponse,
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
application_services().recommended_app_queries.list_recommended(
requested_language=req_data.language,
interface_language=current_user.interface_language,
),
)
@ -133,19 +120,24 @@ class LearnDifyAppListApi(Resource):
@with_current_user
@model_validate(RecommendedAppsQuery)
def get(self, req_data: RecommendedAppsQuery, current_user: Account):
language_prefix = _resolve_language(req_data.language, current_user)
return dump_response(
LearnDifyAppListResponse,
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
application_services().recommended_app_queries.list_learn_dify(
requested_language=req_data.language,
interface_language=current_user.interface_language,
),
)
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailNullableResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(404, "Recommended app not found")
@login_required
@account_initialization_required
def get(self, app_id: UUID):
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")
try:
result = application_services().recommended_app_queries.get_detail(str(app_id))
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)

View File

@ -38,7 +38,7 @@ from controllers.console.app.error import (
SpeechToTextDisabledError,
UnsupportedAudioTypeError,
)
from controllers.console.app.wraps import get_app_model_with_trial, with_session
from controllers.console.app.wraps import get_previewable_app_model, with_session
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
@ -96,7 +96,6 @@ from services.errors.message import (
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from services.recommended_app_service import RecommendedAppService
logger = logging.getLogger(__name__)
@ -511,7 +510,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
@ -589,7 +588,7 @@ class TrialChatApi(TrialAppResource):
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
@ -675,7 +674,7 @@ class TrialChatAudioApi(TrialAppResource):
session=db.session(),
end_user=None,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@ -736,7 +735,7 @@ class TrialChatTextApi(TrialAppResource):
voice=voice,
message_ref=message_ref,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=db.session())
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@ -794,7 +793,7 @@ class TrialCompletionApi(TrialAppResource):
streaming=streaming,
)
RecommendedAppService.add_trial_app_record(app_id, user_id, session=session)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
@ -824,7 +823,7 @@ class TrialSitApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SiteResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Retrieve app site info.
@ -848,7 +847,7 @@ class TrialAppParameterApi(Resource):
@console_ns.response(200, "Success", console_ns.models[ParametersResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Retrieve app parameters."""
@ -866,7 +865,7 @@ class TrialAppParameterApi(Resource):
class AppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialAppDetailResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get app detail"""
@ -882,7 +881,7 @@ class AppApi(Resource):
class AppWorkflowApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TrialWorkflowResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
"""Get workflow detail"""
if not app_model.workflow_id:
@ -902,7 +901,7 @@ class DatasetListApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialDatasetListQuery))
@console_ns.response(200, "Success", console_ns.models[TrialDatasetListResponse.__name__])
@with_session(write=False)
@get_app_model_with_trial(None)
@get_previewable_app_model(None)
def get(self, session: Session, app_model):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)

View File

@ -2,19 +2,23 @@ from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from flask import abort
from flask_restx import Resource
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from controllers.console.explore.error import AppAccessDeniedError, TrialAppLimitExceeded, TrialAppNotAllowed
from controllers.console.explore.error import (
AppAccessDeniedError,
TrialAppFeatureDisabledError,
TrialAppLimitExceeded,
TrialAppNotAllowed,
)
from controllers.console.wraps import account_initialization_required
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.recommended_app_service import RecommendedAppService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@ -107,8 +111,8 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not RecommendedAppService.is_trial_app_enabled():
abort(403, "Trial app feature is not enabled.")
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated

View File

@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel):
"""Query parameter for including secret variables in export."""
include_secret: str = Field(default="false", description="Whether to include secret variables")
class SnippetExportQuery(IncludeSecretQuery):
"""Query parameters for exporting a snippet workflow as DSL."""
workflow_id: str | None = Field(default=None, description="Specific published workflow version to export")

View File

@ -57,12 +57,11 @@ from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models import Account
from models.snippet import CustomizedSnippet
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from services.snippet_generate_service import SnippetGenerateService
from services.snippet_service import SnippetService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -298,9 +297,8 @@ class SnippetPublishedWorkflowApi(Resource):
with Session(db.engine) as session:
snippet = session.merge(snippet)
tenant_id = snippet.tenant_id
try:
workflow, retirement_candidates = snippet_service.publish_workflow(
workflow = snippet_service.publish_workflow(
session=session,
snippet=snippet,
account=current_user,
@ -310,16 +308,6 @@ class SnippetPublishedWorkflowApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
agent_ids=retirement_candidates,
account_id=current_user.id,
)
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return {
"result": "success",
"created_at": workflow_created_at,
@ -480,6 +468,39 @@ class SnippetWorkflowByIdApi(Resource):
response["input_fields"] = snippet.input_fields_list
return response
@console_ns.doc("delete_snippet_workflow_by_id")
@console_ns.doc(description="Delete a published snippet workflow version")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
@console_ns.response(204, "Workflow deleted successfully")
@console_ns.response(400, "Workflow is in use")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, snippet: CustomizedSnippet, workflow_id: str):
"""Delete a published snippet workflow version."""
snippet_service = _snippet_service()
with _snippet_session_maker().begin() as session:
try:
snippet_service.delete_workflow(
session=session,
snippet=snippet,
workflow_id=workflow_id,
)
except WorkflowInUseError as e:
raise BadRequest(str(e))
except DraftWorkflowDeletionError as e:
raise BadRequest(str(e))
except ValueError as e:
raise NotFound(str(e))
return None, 204
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):

View File

@ -3,37 +3,32 @@ from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.wraps import enforce_rbac_access
from controllers.console import console_ns
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
model_validate,
setup_required,
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models import Account
from libs.login import current_account_with_tenant
from machinery.context import RequestContext
from models.enums import TagType
from models.model import Tag
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
TagBindingDeletePayload,
TagService,
UpdateTagPayload,
from services.tag_application_service import (
CreateTagInput,
TagBindingInput,
TagBindingTargetNotFoundError,
TagNameConflictError,
TagNotFoundError,
UpdateTagInput,
)
@ -59,7 +54,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
type: Literal["knowledge", "app", "snippet"] = Field(description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
@ -101,143 +96,158 @@ register_schema_models(
register_response_schema_models(console_ns, SimpleResultResponse, TagResponse, TagListResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None, context: RequestContext) -> None:
if tag_type != TagType.SNIPPET:
return
if not dify_config.RBAC_ENABLED:
return
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
tenant_id=_workspace_id(context),
account_id=context.account_id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str, context: RequestContext) -> None:
if not dify_config.RBAC_ENABLED:
return
_, current_tenant_id = current_account_with_tenant()
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == current_tenant_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
tag_type = application_services().tags.get_tag_type(context, tag_id)
_enforce_snippet_tag_rbac_if_needed(tag_type, context)
def _workspace_id(context: RequestContext) -> str:
if context.active_workspace_id is None:
raise RuntimeError("Console account admission did not resolve an active workspace")
return context.active_workspace_id
def _require_tag_edit_permission(*, allow_dataset_editor: bool) -> None:
current_user, _ = current_account_with_tenant()
if current_user.has_edit_permission:
return
if allow_dataset_editor and current_user.is_dataset_editor:
return
raise Forbidden()
@console_ns.route("/tags")
class TagListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_account_admission()
@console_ns.doc(params=query_params_from_model(TagListQueryParam))
@console_ns.response(200, "Success", console_ns.models[TagListResponse.__name__])
@with_current_tenant_id
@model_validate(TagListQueryParam)
def get(self, req_data: TagListQueryParam, current_tenant_id: str):
tags = TagService.get_tags(req_data.type, current_tenant_id, req_data.keyword, session=db.session())
def get(self, req_data: TagListQueryParam, request_context: RequestContext):
tags = application_services().tags.list_tags(request_context, req_data.type, req_data.keyword)
return dump_response(TagListResponse, tags), 200
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBasePayload)
def post(self, req_data: TagBasePayload, current_user: Account):
def post(self, req_data: TagBasePayload, request_context: RequestContext):
# Allow users with edit permission, or dataset editors (including dataset operators).
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
_enforce_snippet_tag_rbac_if_needed(req_data.type)
tag = TagService.save_tags(SaveTagPayload(name=req_data.name, type=req_data.type), db.session())
_enforce_snippet_tag_rbac_if_needed(req_data.type, request_context)
try:
tag = application_services().tags.create_tag(
request_context,
CreateTagInput(name=req_data.name, type=req_data.type.value),
)
except TagNameConflictError as error:
raise ValueError(str(error)) from None
return dump_response(TagResponse, {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}), 200
return dump_response(TagResponse, tag), 200
@console_ns.route("/tags/<uuid:tag_id>")
class TagUpdateDeleteApi(Resource):
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[TagResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagUpdateRequestPayload)
def patch(self, req_data: TagUpdateRequestPayload, current_user: Account, tag_id: UUID):
def patch(self, req_data: TagUpdateRequestPayload, request_context: RequestContext, tag_id: UUID):
tag_id_str = str(tag_id)
# The role of the current user in the ta table must be admin, owner, or editor
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=req_data.name), tag_id_str, db.session())
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
try:
tag = application_services().tags.update_tag(
request_context,
tag_id_str,
UpdateTagInput(name=req_data.name),
)
except TagNameConflictError as error:
raise ValueError(str(error)) from None
except TagNotFoundError as error:
raise NotFound(str(error)) from None
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session())
return dump_response(TagResponse, tag), 200
return (
dump_response(
TagResponse,
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count},
),
200,
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@console_account_admission()
@console_ns.response(204, "Tag deleted successfully")
def delete(self, tag_id: UUID):
def delete(self, request_context: RequestContext, tag_id: UUID):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session())
_require_tag_edit_permission(allow_dataset_editor=False)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str, request_context)
try:
application_services().tags.delete_tag(request_context, tag_id_str)
except TagNotFoundError as error:
raise NotFound(str(error)) from None
return "", 204
def _require_tag_binding_edit_permission(current_user: Account) -> None:
def _require_tag_binding_edit_permission() -> None:
"""
Ensure the current account can edit tag bindings.
Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant.
"""
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
_require_tag_edit_permission(allow_dataset_editor=True)
def _create_tag_bindings(current_user: Account, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
def _create_tag_bindings(context: RequestContext, payload: TagBindingPayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission()
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session(),
)
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
try:
application_services().tags.create_bindings(
context,
TagBindingInput(
tag_ids=tuple(payload.tag_ids),
target_id=payload.target_id,
type=payload.type.value,
),
)
except TagBindingTargetNotFoundError as error:
raise NotFound(str(error)) from None
return {"result": "success"}, 200
def _remove_tag_bindings(current_user: Account, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
def _remove_tag_bindings(context: RequestContext, payload: TagBindingRemovePayload) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission()
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session(),
)
_enforce_snippet_tag_rbac_if_needed(payload.type, context)
try:
application_services().tags.delete_bindings(
context,
TagBindingInput(
tag_ids=tuple(payload.tag_ids),
target_id=payload.target_id,
type=payload.type.value,
),
)
except TagBindingTargetNotFoundError as error:
raise NotFound(str(error)) from None
return {"result": "success"}, 200
@ -248,13 +258,10 @@ class TagBindingCollectionApi(Resource):
@console_ns.doc("create_tag_binding")
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBindingPayload)
def post(self, req_data: TagBindingPayload, current_user: Account):
return _create_tag_bindings(current_user, req_data)
def post(self, req_data: TagBindingPayload, request_context: RequestContext):
return _create_tag_bindings(request_context, req_data)
@console_ns.route("/tag-bindings/remove")
@ -265,10 +272,7 @@ class TagBindingRemoveApi(Resource):
@console_ns.doc(description="Remove one or more tag bindings from a target.")
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@console_account_admission()
@model_validate(TagBindingRemovePayload)
def post(self, req_data: TagBindingRemovePayload, current_user: Account):
return _remove_tag_bindings(current_user, req_data)
def post(self, req_data: TagBindingRemovePayload, request_context: RequestContext):
return _remove_tag_bindings(request_context, req_data)

View File

@ -2,12 +2,13 @@ from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import Literal
from typing import Annotated, Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@ -28,7 +29,13 @@ from controllers.console.auth.error import (
InvalidEmailError,
InvalidTokenError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
@ -46,6 +53,7 @@ from controllers.console.wraps import (
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@ -53,12 +61,15 @@ from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
from libs.login import login_required
from machinery.context import RequestContext
from models import Account, AccountIntegrate, InvitationCode
from models.account import AccountStatus, InvitationCodeStatus
from models.enums import CreatorUserRole
from models.model import UploadFile
from services import account_errors
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.account_entities import AccountProfileChanges
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
@ -118,6 +129,42 @@ class AccountTimezonePayload(BaseModel):
return timezone(value)
class AccountProfilePatchPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Annotated[str, Field(min_length=3, max_length=30)] | SkipJsonSchema[None] = None
avatar: str | SkipJsonSchema[None] = None
interface_language: str | SkipJsonSchema[None] = None
interface_theme: Literal["light", "dark"] | SkipJsonSchema[None] = None
timezone: str | SkipJsonSchema[None] = None
@field_validator("*", mode="before")
@classmethod
def reject_null(cls, value: object) -> object:
if value is None:
raise ValueError("Account profile fields cannot be null")
return value
@field_validator("interface_language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
return timezone(value)
def to_changes(self) -> AccountProfileChanges:
return AccountProfileChanges(
name=self.name,
avatar=self.avatar,
interface_language=self.interface_language,
interface_theme=self.interface_theme,
timezone=self.timezone,
)
class AccountPasswordPayload(BaseModel):
password: str | None = None
new_password: str
@ -183,6 +230,7 @@ register_schema_models(
AccountInterfaceLanguagePayload,
AccountInterfaceThemePayload,
AccountTimezonePayload,
AccountProfilePatchPayload,
AccountPasswordPayload,
AccountDeletePayload,
AccountDeletionFeedbackPayload,
@ -248,6 +296,14 @@ register_response_schema_models(
)
def _update_account_profile(request_context: RequestContext, changes: AccountProfileChanges) -> dict[str, object]:
try:
account = application_services().accounts.profile.update(request_context, changes)
except account_errors.AccountNotFoundError as error:
raise AccountNotFound() from error
return dump_response(AccountResponse, account)
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@ -305,21 +361,28 @@ class AccountProfileApi(Resource):
def get(self, current_user: Account):
return dump_response(AccountResponse, current_user)
@console_ns.expect(console_ns.models[AccountProfilePatchPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_account_admission()
@model_validate(AccountProfilePatchPayload)
def patch(self, args: AccountProfilePatchPayload, request_context: RequestContext):
return _update_account_profile(request_context, args.to_changes())
@console_ns.route("/account/name")
class AccountNameApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_name_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), name=args.name)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
@console_ns.route("/account/avatar")
@ -350,73 +413,69 @@ class AccountAvatarApi(Resource):
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.doc("update_account_avatar_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountAvatarPayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), avatar=args.avatar)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
@console_ns.route("/account/interface-language")
class AccountInterfaceLanguageApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_language_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_language=args.interface_language
return _update_account_profile(
request_context,
AccountProfileChanges(interface_language=args.interface_language),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-theme")
class AccountInterfaceThemeApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_interface_theme_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountInterfaceThemePayload.model_validate(payload)
updated_account = AccountService.update_account(
current_user, session=db.session(), interface_theme=args.interface_theme
return _update_account_profile(
request_context,
AccountProfileChanges(interface_theme=args.interface_theme),
)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/timezone")
class AccountTimezoneApi(Resource):
"""Deprecated compatibility route; use PATCH /account/profile."""
@console_ns.doc("update_account_timezone_deprecated")
@console_ns.doc(deprecated=True)
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
@console_account_admission()
def post(self, request_context: RequestContext):
payload = console_ns.payload or {}
args = AccountTimezonePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session(), timezone=args.timezone)
return dump_response(AccountResponse, updated_account)
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
@console_ns.route("/account/password")
@ -715,7 +774,10 @@ class ChangeEmailResetApi(Resource):
args = ChangeEmailResetPayload.model_validate(payload)
normalized_new_email = args.new_email.lower()
if AccountService.is_account_in_freeze(normalized_new_email):
freeze_type = AccountService.get_account_freeze_type(normalized_new_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
@ -762,7 +824,10 @@ class CheckEmailUnique(Resource):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
freeze_type = AccountService.get_account_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session()):
raise EmailAlreadyInUseError()

View File

@ -290,6 +290,8 @@ class EndpointListApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@ -318,6 +320,8 @@ class EndpointListForSinglePluginApi(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id

View File

@ -17,7 +17,7 @@ from controllers.console import console_ns
from controllers.console.app.wraps import with_session
from controllers.console.snippets.payloads import (
CreateSnippetPayload,
IncludeSecretQuery,
SnippetExportQuery,
SnippetImportPayload,
SnippetListQuery,
UpdateSnippetPayload,
@ -89,7 +89,7 @@ register_schema_models(
CreateSnippetPayload,
UpdateSnippetPayload,
SnippetImportPayload,
IncludeSecretQuery,
SnippetExportQuery,
)
register_response_schema_models(
console_ns,
@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource):
@console_ns.doc("export_customized_snippet")
@console_ns.doc(description="Export snippet configuration as DSL")
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
@console_ns.doc(params=query_params_from_model(IncludeSecretQuery))
@console_ns.doc(params=query_params_from_model(SnippetExportQuery))
@console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__])
@console_ns.response(404, "Snippet not found")
@setup_required
@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource):
raise NotFound("Snippet not found")
# Get include_secret parameter
query = IncludeSecretQuery.model_validate(request.args.to_dict())
query = SnippetExportQuery.model_validate(request.args.to_dict())
with Session(db.engine) as session:
export_service = SnippetDslService(session)
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
try:
result = export_service.export_snippet_dsl(
snippet=snippet,
include_secret=query.include_secret == "true",
workflow_id=query.workflow_id,
)
except ValueError as exc:
raise NotFound(str(exc)) from exc
# Set filename with .snippet extension
filename = f"{snippet.name}.snippet"

View File

@ -1296,6 +1296,8 @@ class ToolOAuthCustomClient(Resource):
)
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, provider: str):

View File

@ -4,6 +4,7 @@ from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
@ -17,6 +18,7 @@ from models import Account, App
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService, Import
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
@ -53,18 +55,21 @@ class AppDslImportApi(Resource):
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
try:
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
if result.status == ImportStatus.FAILED:
session.rollback()
else:
@ -108,7 +113,10 @@ class AppDslImportConfirmApi(Resource):
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.confirm_import(import_id=import_id, account=account)
try:
result = service.confirm_import(import_id=import_id, account=account)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
if result.status == ImportStatus.FAILED:
session.rollback()
else:

View File

@ -7,33 +7,29 @@ from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import AccessModeResponse, Parameters
from controllers.common.fields import AccessModeResponse, BooleanResultResponse, Parameters
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
WebAppAccessServiceUnavailableError,
WebAppAuthRequiredError,
WebAppNotFoundError,
)
from controllers.web.wraps import WebApiResource
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.webapp_access_query_service import (
WebAppAccessAppNotFoundError,
WebAppAccessReferenceRequiredError,
WebAppAccessUnavailableError,
)
from services.webapp_auth_service import WebAppAuthService
logger = logging.getLogger(__name__)
@ -64,7 +60,7 @@ register_response_schema_models(
Parameters,
AppMetaResponse,
AccessModeResponse,
fields.BooleanResultResponse,
BooleanResultResponse,
)
@ -165,21 +161,23 @@ class AppWebAuthPermission(Resource):
400: "Bad Request",
401: "Unauthorized",
500: "Internal Server Error",
503: "Web App Access Service Unavailable",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
@web_ns.response(200, "Success", web_ns.models[BooleanResultResponse.__name__])
def get(self):
user_id = "visitor"
app_code = request.headers.get(HEADER_NAME_APP_CODE)
app_id = request.args.get("appId")
if not app_id or not app_code:
raise ValueError("appId must be provided")
require_permission_check = WebAppAuthService.is_app_require_permission_check(
app_id=app_id, session=db.session()
)
if not require_permission_check:
return {"result": True}
webapp_access = application_services().webapp_access
try:
requires_permission_check = webapp_access.requires_permission_check(app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
if not requires_permission_check:
return dump_response(BooleanResultResponse, {"result": True})
try:
tk = extract_webapp_passport(app_code, request)
@ -188,16 +186,13 @@ class AppWebAuthPermission(Resource):
decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor")
except Unauthorized:
raise
raise WebAppAuthRequiredError() from None
except Exception:
logger.exception("Unexpected error during auth verification")
raise
features = FeatureService.get_system_features()
if not features.webapp_auth.enabled:
return {"result": True}
res = True
if WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()):
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
return {"result": res}
try:
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
return dump_response(BooleanResultResponse, {"result": is_allowed})

View File

@ -1,23 +1,19 @@
from typing import Any, Self
from pydantic import AliasChoices, Field
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.storage.storage_type import StorageType
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import build_icon_url
from models.account import Tenant, TenantStatus
from models.model import App, AppMode, EndUser, IconType, Site
from libs.helper import build_icon_url, dump_response
from models.account import Tenant
from models.model import App, AppMode, EndUser, Site
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
from services.file_service import FileService
from services.web_app_runtime_query_service import WebAppRuntimeUnavailableError
class WebSiteResponse(ResponseModel):
@ -128,17 +124,6 @@ register_response_schema_models(
)
def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
if site.icon_type != IconType.IMAGE or not site.icon:
return None
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
):
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
return build_icon_url(site.icon_type, site.icon)
@web_ns.route("/site")
class AppSiteApi(WebApiResource):
@web_ns.doc("Get App Site Info")
@ -156,25 +141,21 @@ class AppSiteApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[WebAppSiteResponse.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app site info."""
# get site
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
try:
bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id)
except WebAppRuntimeUnavailableError:
raise Forbidden() from None
if site is None:
raise Forbidden()
tenant = app_model.tenant
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True)
return WebAppSiteResponse.from_app_site(
tenant=tenant,
app_model=app_model,
mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())),
site=site,
end_user_id=end_user.id,
features=features,
can_replace_logo=features.can_replace_logo,
icon_url=_build_site_icon_url(site=site, tenant_id=tenant.id),
).model_dump(mode="json")
return dump_response(
WebAppSiteResponse,
{
"app_id": bootstrap.app_id,
"mode": bootstrap.mode,
"end_user_id": end_user.id,
"enable_site": bootstrap.enable_site,
"site": bootstrap.site,
"plan": bootstrap.plan,
"can_replace_logo": bootstrap.can_replace_logo,
"custom_config": bootstrap.custom_config,
},
)

View File

@ -273,6 +273,17 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
with session_factory.create_session() as session:
err = self.handle_error(event=event, session=session, message_id=self._message_id)
session.commit()
if trace_manager:
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.MESSAGE_TRACE,
conversation_id=self._conversation_id,
message_id=self._message_id,
trace_session_id=self._application_generate_entity.extras.get("trace_session_id"),
)
)
yield self.error_to_stream_response(err)
break
case QueueStopEvent() | QueueMessageEndEvent():

View File

@ -16,10 +16,15 @@ from core.schemas.schema_manager import SchemaManager
from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
from repositories.installation_state_repository import InstallationStateRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
from repositories.tag_repository import TagRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
from repositories.workspace_query_repository import WorkspaceQueryRepository
@ -30,6 +35,7 @@ from services.account_activation_adapters import (
RegisterServiceInvitationTokenStore,
)
from services.account_activation_service import AccountActivationService
from services.account_profile_service import AccountProfileService
from services.app_definition_query_service import AppDefinitionQueryService
from services.auth.data_source_api_key_auth_gateways import (
ProviderApiKeyAuthCredentialValidator,
@ -42,10 +48,20 @@ from services.explore_banner_query_service import ExploreBannerQueryService
from services.feature_query_service import FeatureQueryService
from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
from services.file_service import FileService
from services.init_validation_service import InitValidationService
from services.recommended_app_catalog_gateway import (
BuiltinRecommendedAppCatalogGateway,
RecommendedAppCatalogRouter,
RemoteRecommendedAppCatalogGateway,
)
from services.recommended_app_query_service import RecommendedAppQueryService
from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_service import SetupService
from services.tag_application_service import TagApplicationService
from services.trial_app_usage import TrialAppUsageRecorder
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
from services.webapp_access_query_service import (
WebAppAccessQueryService,
WebAppAccessUnavailableError,
@ -69,19 +85,36 @@ def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
raise WebAppAccessUnavailableError from e
def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
try:
return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id)
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e:
raise WebAppAccessUnavailableError from e
@dataclass(frozen=True, slots=True)
class AccountServices:
profile: AccountProfileService
@dataclass(frozen=True, slots=True)
class ApplicationServices:
accounts: AccountServices
account_activation: AccountActivationService
app_definitions: AppDefinitionQueryService
data_source_api_key_auth: DataSourceApiKeyAuthService
webapp_access: WebAppAccessQueryService
web_app_runtime: WebAppRuntimeQueryService
explore_banner_queries: ExploreBannerQueryService
schema_definitions: SchemaDefinitionService
setup: SetupService
feature_queries: FeatureQueryService
init_validation: InitValidationService
recommended_app_queries: RecommendedAppQueryService
trial_app_usage: TrialAppUsageRecorder
workspace_queries: WorkspaceQueryService
workspace_member_queries: WorkspaceMemberQueryService
tags: TagApplicationService
def build_application_services(
@ -93,7 +126,21 @@ def build_application_services(
) -> ApplicationServices:
installation_state = InstallationStateRepository(client=database_client)
data_source_api_key_auth_bindings = SQLAlchemyDataSourceApiKeyAuthBindingRepository(session_factory=database_client)
app_definition_repository = AppDefinitionQueryRepository(session_factory=database_client)
feature_gateway = FeatureServiceGateway()
trial_app_enabled = FeatureService.is_trial_app_enabled()
database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis)
builtin_catalog = BuiltinRecommendedAppCatalogGateway()
remote_catalog = RemoteRecommendedAppCatalogGateway()
recommended_app_catalog = RecommendedAppCatalogRouter(
remote=remote_catalog,
database=database_catalog,
builtin=builtin_catalog,
)
return ApplicationServices(
accounts=AccountServices(
profile=AccountProfileService(accounts=SQLAlchemyAccountRepository(database_client)),
),
account_activation=AccountActivationService(
tokens=RegisterServiceInvitationTokenStore(),
accounts=SQLAlchemyAccountActivationRepository(database_client),
@ -106,7 +153,7 @@ def build_application_services(
),
),
app_definitions=AppDefinitionQueryService(
definitions=AppDefinitionQueryRepository(session_factory=database_client),
definitions=app_definition_repository,
builtin_icon_url_prefix=(
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
),
@ -120,6 +167,13 @@ def build_application_services(
access=WebAppAccessQueryRepository(session_factory=database_client),
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
access_mode_for_app=_get_enterprise_webapp_access_mode,
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
),
web_app_runtime=WebAppRuntimeQueryService(
runtime=app_definition_repository,
file_service=FileService(database_client),
workspace_features=feature_gateway.get_workspace_features,
files_url=dify_config.FILES_URL,
),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(client=database_client),
@ -133,7 +187,7 @@ def build_application_services(
setup_required=deployment_edition != DeploymentEdition.CLOUD,
),
feature_queries=FeatureQueryService(
features=FeatureServiceGateway(),
features=feature_gateway,
trial_models=FeatureService.get_trial_models(),
app_dsl_version=CURRENT_APP_DSL_VERSION,
),
@ -142,6 +196,12 @@ def build_application_services(
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
expected_password=initialization_password,
),
recommended_app_queries=RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=TrialAppQueryRepository(session_factory=database_client),
trial_enabled=trial_app_enabled,
),
trial_app_usage=TrialAppUsageRepository(session_factory=database_client),
workspace_queries=WorkspaceQueryService(
workspaces=WorkspaceQueryRepository(
client=database_client,
@ -154,6 +214,9 @@ def build_application_services(
),
roles=DeploymentWorkspaceMemberRoleResolver(),
),
tags=TagApplicationService(
tags=TagRepository(session_factory=database_client),
),
)

View File

@ -8,7 +8,7 @@ WorkflowNodeExecutionModel operations using Aliyun SLS LogStore.
import logging
import time
from collections.abc import Mapping, Sequence
from datetime import datetime
from datetime import UTC, datetime
from typing import Any, override
from sqlalchemy.orm import sessionmaker
@ -17,6 +17,7 @@ from extensions.logstore.aliyun_logstore import AliyunLogStore
from extensions.logstore.repositories import safe_float, safe_int
from extensions.logstore.sql_escape import escape_identifier, escape_logstore_query_value
from graphon.enums import WorkflowNodeExecutionStatus
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from models.enums import CreatorUserRole
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
@ -86,28 +87,29 @@ def _dict_to_workflow_node_execution_model(data: dict[str, Any]) -> WorkflowNode
model.execution_metadata = data.get("execution_metadata")
# Handle datetime fields
# Every branch must yield naive UTC, matching what the database path stores.
created_at = data.get("created_at")
match created_at:
case None:
# Provide default created_at if missing
model.created_at = datetime.now()
model.created_at = naive_utc_now()
case str():
model.created_at = datetime.fromisoformat(created_at)
model.created_at = ensure_naive_utc(datetime.fromisoformat(created_at))
case int() | float():
model.created_at = datetime.fromtimestamp(created_at)
model.created_at = datetime.fromtimestamp(created_at, tz=UTC).replace(tzinfo=None)
case _:
model.created_at = created_at
model.created_at = ensure_naive_utc(created_at)
finished_at = data.get("finished_at")
match finished_at:
case None:
...
case str():
model.finished_at = datetime.fromisoformat(finished_at)
model.finished_at = ensure_naive_utc(datetime.fromisoformat(finished_at))
case int() | float():
model.finished_at = datetime.fromtimestamp(finished_at)
model.finished_at = datetime.fromtimestamp(finished_at, tz=UTC).replace(tzinfo=None)
case _:
model.finished_at = finished_at
model.finished_at = ensure_naive_utc(finished_at)
return model

View File

@ -17,7 +17,7 @@ import logging
import os
import time
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime
from typing import Any, cast, override
from sqlalchemy.orm import sessionmaker
@ -26,6 +26,7 @@ from extensions.logstore.aliyun_logstore import AliyunLogStore
from extensions.logstore.repositories import safe_float, safe_int
from extensions.logstore.sql_escape import escape_identifier, escape_logstore_query_value, escape_sql_string
from graphon.enums import WorkflowExecutionStatus
from libs.datetime_utils import ensure_naive_utc, naive_utc_now
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.workflow import WorkflowRun, WorkflowType
@ -104,28 +105,30 @@ def _dict_to_workflow_run(data: dict[str, Any]) -> WorkflowRun:
model.error = data.get("error_message") or data.get("error")
# Handle datetime fields
# Every branch must yield naive UTC, matching what the database path stores.
# Mixing naive local time and aware values here breaks the elapsed_time subtraction below.
started_at = data.get("started_at") or data.get("created_at")
if started_at:
match started_at:
case str():
model.created_at = datetime.fromisoformat(started_at)
model.created_at = ensure_naive_utc(datetime.fromisoformat(started_at))
case int() | float():
model.created_at = datetime.fromtimestamp(started_at)
model.created_at = datetime.fromtimestamp(started_at, tz=UTC).replace(tzinfo=None)
case _:
model.created_at = started_at
model.created_at = ensure_naive_utc(started_at)
else:
# Provide default created_at if missing
model.created_at = datetime.now()
model.created_at = naive_utc_now()
finished_at = data.get("finished_at")
if finished_at:
match finished_at:
case str():
model.finished_at = datetime.fromisoformat(finished_at)
model.finished_at = ensure_naive_utc(datetime.fromisoformat(finished_at))
case int() | float():
model.finished_at = datetime.fromtimestamp(finished_at)
model.finished_at = datetime.fromtimestamp(finished_at, tz=UTC).replace(tzinfo=None)
case _:
model.finished_at = finished_at
model.finished_at = ensure_naive_utc(finished_at)
# Compute elapsed_time from started_at and finished_at
# LogStore doesn't store elapsed_time, it's computed in WorkflowExecution domain entity

View File

@ -1,12 +1,12 @@
import json
import logging
import os
import time
from typing import override
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.repositories.factory import WorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from extensions.logstore.aliyun_logstore import AliyunLogStore
@ -71,14 +71,12 @@ class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
triggered_from=triggered_from,
)
# Control flag for dual-write (write to both LogStore and SQL database)
# Set to True to enable dual-write for safe migration, False to use LogStore only
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
self._enable_put_graph_field = os.environ.get("LOGSTORE_ENABLE_PUT_GRAPH_FIELD", "true").lower() == "true"
self._enable_put_graph_field = dify_config.LOGSTORE_ENABLE_PUT_GRAPH_FIELD
def _to_logstore_model(self, domain_model: WorkflowExecution) -> list[tuple[str, str]]:
"""

View File

@ -7,7 +7,6 @@ using Aliyun SLS LogStore with append-only writes and version control.
import json
import logging
import os
import time
from collections.abc import Sequence
from datetime import datetime
@ -16,6 +15,7 @@ from typing import Any, override
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.ops.utils import JSON_DICT_ADAPTER
from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository
@ -152,9 +152,9 @@ class LogstoreWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
triggered_from=triggered_from,
)
# Control flag for dual-write (write to both LogStore and SQL database)
# Set to True to enable dual-write for safe migration, False to use LogStore only
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "false").lower() == "true"
# Keep the migration switch on the typed application config so callers
# and tests share the same validated source.
self._enable_dual_write = dify_config.LOGSTORE_DUAL_WRITE_ENABLED
def _to_logstore_model(self, domain_model: WorkflowNodeExecution) -> Sequence[tuple[str, str]]:
logger.debug(

View File

@ -0,0 +1,55 @@
"""clean legacy agent soul files
Revision ID: fbdfcf5f5a6e
Revises: 89919253ca7a
Create Date: 2026-08-20 09:38:36.827807
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "fbdfcf5f5a6e"
down_revision = "89919253ca7a"
branch_labels = None
depends_on = None
def _remove_legacy_files(table_name: str) -> None:
dialect_name = op.get_context().dialect.name
if dialect_name == "postgresql":
op.execute(
f"""UPDATE {table_name}
SET config_snapshot = (config_snapshot::jsonb - 'files')::text
WHERE config_snapshot::jsonb ? 'files'"""
)
return
if dialect_name == "mysql":
op.execute(
f"""UPDATE {table_name}
SET config_snapshot = JSON_REMOVE(config_snapshot, '$.files')
WHERE JSON_CONTAINS_PATH(config_snapshot, 'one', '$.files')"""
)
return
if dialect_name == "sqlite":
op.execute(
f"""UPDATE {table_name}
SET config_snapshot = json_remove(config_snapshot, '$.files')
WHERE json_type(config_snapshot, '$.files') IS NOT NULL"""
)
return
raise RuntimeError(f"unsupported database dialect: {dialect_name}")
def upgrade() -> None:
# The Agent Drive removal migration skipped its Python row rewrite when
# migrations were emitted as offline SQL. Run the cleanup again as native
# SQL so every deployment removes the retired AgentSoulConfig.files field.
_remove_legacy_files("agent_config_snapshots")
_remove_legacy_files("agent_config_drafts")
def downgrade() -> None:
# The retired files catalog cannot be reconstructed after Agent Drive data
# has been removed.
pass

View File

@ -27,7 +27,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AvatarUrlResponse](#avatarurlresponse)<br> |
### [POST] /account/avatar
### ~~[POST] /account/avatar~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@ -187,7 +192,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountIntegrateListResponse](#accountintegratelistresponse)<br> |
### [POST] /account/interface-language
### ~~[POST] /account/interface-language~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@ -200,7 +210,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/interface-theme
### ~~[POST] /account/interface-theme~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@ -213,7 +228,12 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/name
### ~~[POST] /account/name~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@ -246,7 +266,25 @@ Get account avatar url
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /account/timezone
### [PATCH] /account/profile
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AccountProfilePatchPayload](#accountprofilepatchpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### ~~[POST] /account/timezone~~
***DEPRECATED***
Deprecated. Use PATCH /account/profile instead.
#### Request Body
| Required | Schema |
@ -6526,7 +6564,8 @@ Check if dataset is in use
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [RecommendedAppDetailNullableResponse](#recommendedappdetailnullableresponse)<br> |
| 200 | Success | **application/json**: [RecommendedAppDetailResponse](#recommendedappdetailresponse)<br> |
| 404 | Recommended app not found | |
### [GET] /features
**Get feature configuration for current tenant**
@ -11070,6 +11109,24 @@ Reset a draft workflow variable to its default value (snippet scope)
| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)<br> |
| 400 | No draft workflow found | |
### [DELETE] /snippets/{snippet_id}/workflows/{workflow_id}
**Delete a published snippet workflow version**
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| snippet_id | path | Snippet ID | Yes | string (uuid) |
| workflow_id | path | Workflow ID | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 204 | Workflow deleted successfully |
| 400 | Workflow is in use |
| 404 | Workflow not found |
### [PATCH] /snippets/{snippet_id}/workflows/{workflow_id}
**Update a published snippet workflow version's display metadata**
@ -11185,7 +11242,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword | No | string |
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
| type | query | Tag type filter | Yes | string, <br>**Available values:** "app", "knowledge", "snippet" |
#### Responses
@ -11850,6 +11907,7 @@ Export snippet configuration as DSL
| ---- | ---------- | ----------- | -------- | ------ |
| snippet_id | path | Snippet ID to export | Yes | string (uuid) |
| include_secret | query | Whether to include secret variables | No | string, <br>**Default:** false |
| workflow_id | query | Specific published workflow version to export | No | string |
#### Responses
@ -14749,6 +14807,16 @@ Model class for AI model.
| password | string | | No |
| repeat_new_password | string | | Yes |
#### AccountProfilePatchPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| avatar | string | | No |
| interface_language | string | | No |
| interface_theme | string, <br>**Available values:** "dark", "light" | *Enum:* `"dark"`, `"light"` | No |
| name | string | | No |
| timezone | string | | No |
#### AccountResponse
| Name | Type | Description | Required |
@ -20130,11 +20198,9 @@ How Dify forwards the end-user's identity to an MCP server.
#### IncludeSecretQuery
Query parameter for including secret variables in export.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| include_secret | string, <br>**Default:** false | Whether to include secret variables | No |
| include_secret | string, <br>**Default:** false | Whether to include secret values in the exported DSL | No |
#### IndexingEstimate
@ -24903,12 +24969,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| permission_keys | [ string ] | | No |
| updated_at | integer | | Yes |
#### RecommendedAppDetailNullableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| RecommendedAppDetailNullableResponse | [RecommendedAppDetailResponse](#recommendedappdetailresponse) | | |
#### RecommendedAppDetailResponse
| Name | Type | Description | Required |
@ -25558,6 +25618,15 @@ Payload for syncing snippet draft workflow.
| hash | string | | No |
| input_fields | [ object ] | | No |
#### SnippetExportQuery
Query parameters for exporting a snippet workflow as DSL.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| include_secret | string, <br>**Default:** false | Whether to include secret variables | No |
| workflow_id | string | Specific published workflow version to export | No |
#### SnippetImportPayload
Payload for importing snippet from DSL.
@ -25992,7 +26061,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword | No |
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
| type | string, <br>**Available values:** "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `"app"`, `"knowledge"`, `"snippet"` | Yes |
#### TagListResponse

View File

@ -847,6 +847,7 @@ Check if user has permission to access a web application.
| 400 | Bad Request | |
| 401 | Unauthorized | |
| 500 | Internal Server Error | |
| 503 | Web App Access Service Unavailable | |
### [POST] /workflows/run
**Run workflow**

View File

@ -6,7 +6,7 @@ requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.4.0,<7.0.0",
"boto3>=1.43.56,<2.0.0",
"boto3>=1.43.71,<2.0.0",
"celery>=5.6.3,<6.0.0",
"croniter>=6.2.2,<7.0.0",
"dify-agent",
@ -31,7 +31,7 @@ dependencies = [
"flask-migrate>=4.1.0,<5.0.0",
"flask-orjson>=2.0.0,<3.0.0",
"flask-restx>=1.3.2,<2.0.0",
"google-cloud-aiplatform>=1.160.0,<2.0.0",
"google-cloud-aiplatform>=1.164.0,<2.0.0",
"httpx[socks]==0.28.1",
"opentelemetry-distro==0.65b0",
"opentelemetry-instrumentation-celery==0.65b0",
@ -207,7 +207,7 @@ storage = [
"bce-python-sdk==0.9.76",
"cos-python-sdk-v5>=1.9.44,<2.0.0",
"esdk-obs-python>=3.26.6,<4.0.0",
"google-cloud-storage>=3.13.0,<4.0.0",
"google-cloud-storage>=3.13.1,<4.0.0",
"opendal==0.46.0",
"oss2>=2.19.1,<3.0.0",
"supabase>=2.31.0,<3.0.0",

View File

@ -0,0 +1,59 @@
"""SQLAlchemy implementation of the account persistence port."""
from typing import override
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account
from services.account_ports import AccountRepository
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class SQLAlchemyAccountRepository(AccountRepository):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def get(self, account_id: str) -> AccountSnapshot | None:
with self._session_factory() as session:
account = session.get(Account, account_id)
return self._to_snapshot(account) if account is not None else None
@override
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None:
with self._session_factory.begin() as session:
account = session.get(Account, account_id)
if account is None:
return None
if changes.name is not None:
account.name = changes.name
if changes.avatar is not None:
account.avatar = changes.avatar
if changes.interface_language is not None:
account.interface_language = changes.interface_language
if changes.interface_theme is not None:
account.interface_theme = changes.interface_theme
if changes.timezone is not None:
account.timezone = changes.timezone
session.flush()
return self._to_snapshot(account)
@staticmethod
def _to_snapshot(account: Account) -> AccountSnapshot:
return AccountSnapshot(
id=account.id,
name=account.name,
email=account.email,
avatar=account.avatar,
is_password_set=account.is_password_set,
interface_language=account.interface_language,
interface_theme=account.interface_theme,
timezone=account.timezone,
last_login_at=account.last_login_at,
last_login_ip=account.last_login_ip,
status=account.status.value,
initialized_at=account.initialized_at,
created_at=account.created_at,
)

View File

@ -9,6 +9,7 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from models.account import Tenant, TenantStatus
from models.agent import AgentConfigSnapshot
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
@ -21,6 +22,27 @@ from services.app_definition_query_service import (
AppSiteConfiguration,
AppToolIconSource,
)
from services.web_app_runtime_query_service import WebAppRuntimeRecord
def _map_site_configuration(site: Site) -> AppSiteConfiguration:
return AppSiteConfiguration(
title=site.title,
chat_color_theme=site.chat_color_theme,
chat_color_theme_inverted=site.chat_color_theme_inverted,
icon_type=site.icon_type.value if site.icon_type is not None else None,
icon=site.icon,
icon_background=site.icon_background,
description=site.description,
copyright=site.copyright,
privacy_policy=site.privacy_policy,
input_placeholder=site.input_placeholder,
custom_disclaimer=site.custom_disclaimer,
default_language=site.default_language,
prompt_public=site.prompt_public,
show_workflow_steps=site.show_workflow_steps,
use_icon_as_answer_icon=site.use_icon_as_answer_icon,
)
def _get_public_agent_parameter_config(app: App, *, session: Session) -> AppParameterConfig:
@ -153,21 +175,41 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
if site is None:
return None
return AppSiteConfiguration(
title=site.title,
chat_color_theme=site.chat_color_theme,
chat_color_theme_inverted=site.chat_color_theme_inverted,
icon_type=site.icon_type.value if site.icon_type is not None else None,
icon=site.icon,
icon_background=site.icon_background,
description=site.description,
copyright=site.copyright,
privacy_policy=site.privacy_policy,
input_placeholder=site.input_placeholder,
custom_disclaimer=site.custom_disclaimer,
default_language=site.default_language,
show_workflow_steps=site.show_workflow_steps,
use_icon_as_answer_icon=site.use_icon_as_answer_icon,
return _map_site_configuration(site)
def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None:
with self._session_factory() as session:
app = session.get(App, app_id)
if app is None:
return None
site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1))
if site is None:
return None
tenant = session.get(Tenant, app.tenant_id)
if tenant is None:
return None
app_id = app.id
tenant_id = app.tenant_id
enable_site = app.enable_site
site_configuration = _map_site_configuration(site)
plan = tenant.plan
tenant_status = tenant.status.value
tenant_custom_config_json = tenant.custom_config
mode = AppMode.value_of(app.mode).value
if tenant.status != TenantStatus.ARCHIVE:
mode = AppMode.value_of(app.mode_compatible_with_agent_with_session(session=session)).value
return WebAppRuntimeRecord(
app_id=app_id,
tenant_id=tenant_id,
mode=mode,
enable_site=enable_site,
site=site_configuration,
plan=plan,
tenant_status=tenant_status,
tenant_custom_config_json=tenant_custom_config_json,
)
@staticmethod

View File

@ -0,0 +1,191 @@
"""Database-backed recommended app catalog adapter."""
import json
import logging
from collections.abc import Sequence
from typing import cast, override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from constants.languages import languages
from extensions.ext_redis import RedisClientWrapper
from models.model import App, RecommendedApp
from services.app_dsl_service import AppDslService
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppRecord,
)
logger = logging.getLogger(__name__)
# Keep the legacy "explore" Redis key: Explore was the former UI name for this recommended-app surface.
_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
def __init__(self, session_factory: sessionmaker[Session], *, redis: RedisClientWrapper) -> None:
self._session_factory = session_factory
self._redis = redis
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
with self._session_factory() as session:
recommended_apps = self._list_rows(language, session=session)
if not recommended_apps:
recommended_apps = self._list_rows(languages[0], session=session)
records, categories = self._map_rows(recommended_apps, session=session)
return RecommendedAppCatalogPage(
recommended_apps=records,
categories=tuple(self._order_categories(categories, language)),
)
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
with self._session_factory() as session:
recommended_apps = self._list_rows(language, session=session, is_learn_dify=True)
if not recommended_apps and language != languages[0]:
recommended_apps = self._list_rows(languages[0], session=session, is_learn_dify=True)
records, _ = self._map_rows(recommended_apps, session=session)
return RecommendedAppCatalogPage(recommended_apps=records, categories=())
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
with self._session_factory() as session:
return self._get_detail(app_id, session=session)
@override
def contains(self, app_id: str) -> bool:
with self._session_factory() as session:
return (
session.scalar(
select(RecommendedApp.app_id)
.join(App, App.id == RecommendedApp.app_id)
.where(
RecommendedApp.app_id == app_id,
RecommendedApp.is_listed.is_(True),
App.is_public.is_(True),
)
.limit(1)
)
is not None
)
def _order_categories(self, categories: set[str], language: str) -> list[str]:
try:
raw_categories = self._redis.get(f"{_CATEGORY_ORDER_KEY_PREFIX}:{language}")
except Exception:
logger.exception("Failed to read recommended app category order from Redis.")
return sorted(categories)
if not raw_categories:
return sorted(categories)
if isinstance(raw_categories, bytes):
raw_categories = raw_categories.decode("utf-8")
try:
configured_order = json.loads(raw_categories)
except (TypeError, json.JSONDecodeError):
logger.warning("Invalid recommended app category order payload for language %s.", language)
return sorted(categories)
if not isinstance(configured_order, list):
return sorted(categories)
string_order = [category for category in configured_order if isinstance(category, str)]
return string_order or sorted(categories)
@staticmethod
def _list_rows(
language: str,
*,
session: Session,
is_learn_dify: bool | None = None,
) -> list[RecommendedApp]:
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
if is_learn_dify is not None:
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
return list(session.scalars(select(RecommendedApp).where(*filters)).all())
@classmethod
def _map_rows(
cls,
recommended_apps: Sequence[RecommendedApp],
*,
session: Session,
) -> tuple[tuple[RecommendedAppRecord, ...], set[str]]:
categories: set[str] = set()
records: list[RecommendedAppRecord] = []
for recommended_app in recommended_apps:
app = session.get(App, recommended_app.app_id)
if app is None or not app.is_public:
continue
site = app.site_with_session(session=session)
if site is None:
continue
app_categories = cls._as_string_tuple(recommended_app.categories or (), field="categories")
records.append(
RecommendedAppRecord(
app=RecommendedAppInfoRecord(
id=app.id,
name=app.name,
mode=app.mode.value,
icon=cast(str | None, app.icon),
icon_type=app.icon_type.value if app.icon_type is not None else None,
icon_background=app.icon_background,
),
app_id=recommended_app.app_id,
description=cast(str | None, site.description),
copyright=cast(str | None, site.copyright),
privacy_policy=cast(str | None, site.privacy_policy),
custom_disclaimer=cast(str | None, site.custom_disclaimer),
categories=app_categories,
position=recommended_app.position,
is_listed=recommended_app.is_listed,
)
)
categories.update(app_categories)
return tuple(records), categories
@staticmethod
def _get_detail(app_id: str, *, session: Session) -> RecommendedAppDetailRecord | None:
recommended_app = session.scalar(
select(RecommendedApp)
.where(
RecommendedApp.is_listed.is_(True),
RecommendedApp.app_id == app_id,
)
.limit(1)
)
if recommended_app is None:
return None
app = session.get(App, app_id)
if app is None or not app.is_public:
return None
return RecommendedAppDetailRecord(
id=app.id,
name=app.name,
icon=cast(str | None, app.icon),
icon_background=app.icon_background,
mode=app.mode.value,
export_data=AppDslService.export_dsl(app_model=app, session=session),
)
@staticmethod
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
raise TypeError(f"{field} must be a sequence of strings")
items: list[str] = []
for item in value:
if not isinstance(item, str):
raise TypeError(f"{field} must contain only strings")
items.append(item)
return tuple(items)

View File

@ -0,0 +1,214 @@
"""SQLAlchemy persistence adapter for Console tag management."""
import uuid
from typing import override
import sqlalchemy as sa
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session, sessionmaker
from libs.helper import escape_like_pattern
from models.dataset import Dataset
from models.enums import TagType
from models.model import App, Tag, TagBinding
from models.snippet import CustomizedSnippet
from services.tag_application_service import (
CreateTagInput,
InvalidTagBindingTypeError,
TagBindingInput,
TagBindingTargetNotFoundError,
TagNameConflictError,
TagNotFoundError,
TagStore,
TagSummary,
UpdateTagInput,
)
class TagRepository(TagStore):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def list_tags(self, workspace_id: str, tag_type: str, keyword: str | None) -> tuple[TagSummary, ...]:
stmt = (
select(Tag.id, Tag.name, Tag.type, func.count(TagBinding.id))
.outerjoin(
TagBinding,
sa.and_(TagBinding.tag_id == Tag.id, TagBinding.tenant_id == workspace_id),
)
.where(Tag.type == tag_type, Tag.tenant_id == workspace_id)
)
if keyword:
escaped_keyword = escape_like_pattern(keyword)
stmt = stmt.where(Tag.name.ilike(f"%{escaped_keyword}%", escape="\\"))
stmt = stmt.group_by(Tag.id, Tag.name, Tag.type, Tag.created_at).order_by(Tag.created_at.desc())
with self._session_factory() as session:
return tuple(
TagSummary(
id=tag_id,
name=name,
type=tag_kind.value,
binding_count=binding_count,
)
for tag_id, name, tag_kind, binding_count in session.execute(stmt).all()
)
@override
def get_tag_type(self, workspace_id: str, tag_id: str) -> str | None:
with self._session_factory() as session:
tag_type = session.scalar(select(Tag.type).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
return tag_type.value if tag_type is not None else None
@override
def create_tag(self, workspace_id: str, actor_id: str, tag: CreateTagInput) -> TagSummary:
with self._session_factory.begin() as session:
existing = session.scalar(
select(Tag.id).where(Tag.name == tag.name, Tag.tenant_id == workspace_id, Tag.type == tag.type).limit(1)
)
if existing is not None:
raise TagNameConflictError
model = Tag(
name=tag.name,
type=TagType(tag.type),
created_by=actor_id,
tenant_id=workspace_id,
)
model.id = str(uuid.uuid4())
session.add(model)
session.flush()
return self._summary(model, binding_count=0)
@override
def update_tag(self, workspace_id: str, tag_id: str, tag: UpdateTagInput) -> TagSummary:
with self._session_factory.begin() as session:
model = session.scalar(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
if model is None:
raise TagNotFoundError
if tag.name != model.name:
existing = session.scalar(
select(Tag.id)
.where(
Tag.name == tag.name,
Tag.tenant_id == workspace_id,
Tag.type == model.type,
Tag.id != tag_id,
)
.limit(1)
)
if existing is not None:
raise TagNameConflictError
model.name = tag.name
binding_count = (
session.scalar(
select(func.count(TagBinding.id)).where(
TagBinding.tag_id == tag_id,
TagBinding.tenant_id == workspace_id,
)
)
or 0
)
return self._summary(model, binding_count=binding_count)
@override
def delete_tag(self, workspace_id: str, tag_id: str) -> None:
with self._session_factory.begin() as session:
model = session.scalar(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == workspace_id).limit(1))
if model is None:
raise TagNotFoundError
session.execute(
delete(TagBinding).where(
TagBinding.tag_id == tag_id,
TagBinding.tenant_id == workspace_id,
)
)
session.delete(model)
@override
def create_bindings(self, workspace_id: str, actor_id: str, binding: TagBindingInput) -> None:
with self._session_factory.begin() as session:
self._ensure_target_exists(session, workspace_id, binding)
requested_tag_ids = tuple(dict.fromkeys(binding.tag_ids))
if not requested_tag_ids:
return
valid_tag_ids = tuple(
session.scalars(
select(Tag.id).where(
Tag.id.in_(requested_tag_ids),
Tag.tenant_id == workspace_id,
Tag.type == binding.type,
)
).all()
)
if not valid_tag_ids:
return
existing_tag_ids = set(
session.scalars(
select(TagBinding.tag_id).where(
TagBinding.tag_id.in_(valid_tag_ids),
TagBinding.target_id == binding.target_id,
TagBinding.tenant_id == workspace_id,
)
).all()
)
session.add_all(
TagBinding(
tag_id=tag_id,
target_id=binding.target_id,
tenant_id=workspace_id,
created_by=actor_id,
)
for tag_id in valid_tag_ids
if tag_id not in existing_tag_ids
)
@override
def delete_bindings(self, workspace_id: str, binding: TagBindingInput) -> None:
with self._session_factory.begin() as session:
self._ensure_target_exists(session, workspace_id, binding)
session.execute(
delete(TagBinding).where(
TagBinding.target_id == binding.target_id,
TagBinding.tag_id.in_(binding.tag_ids),
TagBinding.tenant_id == workspace_id,
TagBinding.tag_id.in_(
select(Tag.id).where(
Tag.tenant_id == workspace_id,
Tag.type == binding.type,
)
),
)
)
@staticmethod
def _summary(tag: Tag, *, binding_count: int) -> TagSummary:
return TagSummary(
id=tag.id,
name=tag.name,
type=tag.type.value,
binding_count=binding_count,
)
@staticmethod
def _ensure_target_exists(session: Session, workspace_id: str, binding: TagBindingInput) -> None:
if binding.type == "knowledge":
stmt = select(Dataset.id).where(Dataset.tenant_id == workspace_id, Dataset.id == binding.target_id)
elif binding.type == "app":
stmt = select(App.id).where(App.tenant_id == workspace_id, App.id == binding.target_id)
elif binding.type == "snippet":
stmt = select(CustomizedSnippet.id).where(
CustomizedSnippet.tenant_id == workspace_id,
CustomizedSnippet.id == binding.target_id,
)
else:
raise InvalidTagBindingTypeError
if session.scalar(stmt.limit(1)) is None:
raise TagBindingTargetNotFoundError(binding.type)

View File

@ -0,0 +1,23 @@
"""Database repository for recommended app trial eligibility."""
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.model import TrialApp
from services.recommended_app_query_service import TrialAppQuery
class TrialAppQueryRepository(TrialAppQuery):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]:
if not app_ids:
return frozenset()
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())

View File

@ -0,0 +1,28 @@
"""Database repository for recommended trial app usage."""
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord
from services.trial_app_usage import TrialAppUsageRecorder
class TrialAppUsageRepository(TrialAppUsageRecorder):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def record(self, *, app_id: str, account_id: str) -> None:
"""Increment usage without committing the caller's request transaction."""
with self._session_factory() as session, session.begin():
record = session.scalar(
select(AccountTrialAppRecord)
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
.limit(1)
)
if record is None:
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=1))
else:
record.count += 1

View File

@ -54,8 +54,10 @@ class BillingAccountActivationEligibility(AccountActivationEligibility):
self._enabled = enabled
@override
def is_frozen(self, email: str) -> bool:
return self._enabled and BillingService.is_email_in_freeze(email)
def get_freeze_type(self, email: str) -> str | None:
if not self._enabled:
return None
return BillingService.get_email_freeze_type(email)
class BillingWorkspaceMembershipCache(WorkspaceMembershipCache):

View File

@ -41,7 +41,7 @@ class WorkspaceInvitePolicy(Protocol):
class AccountActivationEligibility(Protocol):
def is_frozen(self, email: str) -> bool: ...
def get_freeze_type(self, email: str) -> str | None: ...
class WorkspaceMembershipCache(Protocol):
@ -60,6 +60,10 @@ class FrozenAccountError(Exception):
"""The invited account is temporarily ineligible for activation."""
class EmailDomainSuspendedError(Exception):
"""The invited account uses a suspended email domain."""
class AccountActivationService:
def __init__(
self,
@ -101,7 +105,10 @@ class AccountActivationService:
if authenticated_account_id is not None and authenticated_account_id != invitation.account_id:
raise InvitationAccountMismatchError
if self._eligibility.is_frozen(invitation.account_email):
freeze_type = self._eligibility.get_freeze_type(invitation.account_email)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError
if freeze_type:
raise FrozenAccountError
setup = self._resolve_setup(invitation, command)

View File

@ -0,0 +1,9 @@
"""Framework-neutral errors shared by account application services."""
class AccountApplicationError(Exception):
"""Base class for failures owned by account application services."""
class AccountNotFoundError(AccountApplicationError):
"""The admitted account no longer exists."""

View File

@ -0,0 +1,11 @@
"""Persistence ports used by account application services."""
from typing import Protocol
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class AccountRepository(Protocol):
def get(self, account_id: str) -> AccountSnapshot | None: ...
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ...

View File

@ -0,0 +1,26 @@
"""Application service for reading and updating the current account profile."""
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.account_ports import AccountRepository
from services.entities.account_entities import AccountProfileChanges, AccountSnapshot
class AccountProfileService:
def __init__(self, *, accounts: AccountRepository) -> None:
self._accounts = accounts
def get(self, context: RequestContext) -> AccountSnapshot:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
return account
def update(self, context: RequestContext, changes: AccountProfileChanges) -> AccountSnapshot:
if changes.has_changes():
account = self._accounts.update_profile(context.account_id, changes)
else:
account = self._accounts.get(context.account_id)
if account is None:
raise AccountNotFoundError
return account

View File

@ -67,6 +67,7 @@ from services.errors.account import (
AccountRegisterError,
CannotOperateSelfError,
CurrentPasswordIncorrectError,
EmailDomainSuspendedError,
InvalidActionError,
LinkAccountIntegrateError,
MemberNotInTenantError,
@ -470,6 +471,9 @@ class AccountService:
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@ -1070,6 +1074,9 @@ class AccountService:
@classmethod
def get_user_through_email(cls, email: str, *, session: Session):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@ -1088,9 +1095,13 @@ class AccountService:
@classmethod
def is_account_in_freeze(cls, email: str) -> bool:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
return True
return False
return cls.get_account_freeze_type(email) is not None
@classmethod
def get_account_freeze_type(cls, email: str):
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return None
return BillingService.get_email_freeze_type(email)
@staticmethod
@redis_fallback(default_return=None)

View File

@ -279,16 +279,11 @@ class AgentComposerService:
)
state["validation"] = cls.collect_validation_findings(payload=payload)
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
agent_ids=retirement_candidates,
account_id=account_id,
)
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return state
@classmethod

View File

@ -0,0 +1,103 @@
"""Hard-delete archived Agent aggregates after external resources are collected."""
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete, select
from core.db.session_factory import session_factory
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigRevision,
AgentConfigSnapshot,
AgentDebugConversation,
AgentHomeSnapshot,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
)
class AgentDeletionInvariantError(RuntimeError):
"""An archived Agent no longer satisfies the hard-deletion contract."""
class AgentDeletionService:
"""Delete archived Agent aggregates after their external resources are gone.
The aggregate includes Agent-owned configuration, debug, Home, and Workspace
Binding rows. Workflow-owned binding soft references are outside the
aggregate and may remain dangling after deletion.
"""
@classmethod
def purge_archived_agents(cls, *, tenant_id: str, agent_ids: Iterable[str]) -> None:
"""Idempotently hard-delete eligible archived Agent aggregates.
Missing targets are a no-op. Every stored target must be ``ARCHIVED``,
have no ACTIVE Workspace Binding or Home Snapshot, and all dependent rows
and Agents are deleted and committed in one transaction; an exception
before commit leaves the transaction to roll back without a partial
aggregate deletion.
"""
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
if not candidates:
return
with session_factory.create_session() as session:
agents = session.scalars(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id.in_(candidates))).all()
if not agents:
return
stored_ids = [agent.id for agent in agents]
non_archived_ids = [agent.id for agent in agents if agent.status != AgentStatus.ARCHIVED]
if non_archived_ids:
raise AgentDeletionInvariantError(
f"Agents must be ARCHIVED before deletion: {', '.join(non_archived_ids)}"
)
active_binding_id = session.scalar(
select(AgentWorkspaceBinding.id)
.where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.agent_id.in_(stored_ids),
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
.limit(1)
)
if active_binding_id is not None:
raise AgentDeletionInvariantError(f"Agent aggregate still has ACTIVE Binding {active_binding_id}")
active_home_id = session.scalar(
select(AgentHomeSnapshot.id)
.where(
AgentHomeSnapshot.tenant_id == tenant_id,
AgentHomeSnapshot.agent_id.in_(stored_ids),
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
)
.limit(1)
)
if active_home_id is not None:
raise AgentDeletionInvariantError(f"Agent aggregate still has ACTIVE Home Snapshot {active_home_id}")
for model in (
AgentDebugConversation,
AgentConfigRevision,
AgentConfigDraft,
AgentConfigSnapshot,
AgentHomeSnapshot,
AgentWorkspaceBinding,
):
session.execute(
delete(model).where(
model.tenant_id == tenant_id,
model.agent_id.in_(stored_ids),
)
)
session.execute(delete(Agent).where(Agent.tenant_id == tenant_id, Agent.id.in_(stored_ids)))
session.commit()
__all__ = ["AgentDeletionInvariantError", "AgentDeletionService"]

View File

@ -15,7 +15,6 @@ from libs.uuid_utils import uuidv7
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentHomeSnapshot,
AgentStatus,
@ -108,13 +107,13 @@ class AgentHomeSnapshotService:
select(AgentHomeSnapshot).where(
AgentHomeSnapshot.tenant_id == tenant_id,
AgentHomeSnapshot.agent_id == agent_id,
AgentHomeSnapshot.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
now = naive_utc_now()
for row in rows:
row.status = AgentWorkingResourceStatus.RETIRED
row.retired_at = now
if row.status == AgentWorkingResourceStatus.ACTIVE:
row.status = AgentWorkingResourceStatus.RETIRED
row.retired_at = now
return [row.id for row in rows]
@classmethod
@ -129,13 +128,6 @@ class AgentHomeSnapshotService:
)
if snapshot is None:
return
referenced = session.scalar(
select(AgentConfigDraft.id).where(AgentConfigDraft.home_snapshot_id == home_snapshot_id).limit(1)
) or session.scalar(
select(AgentConfigSnapshot.id).where(AgentConfigSnapshot.home_snapshot_id == home_snapshot_id).limit(1)
)
if referenced is not None:
return
snapshot_ref = snapshot.snapshot_ref
cls.delete(snapshot_ref=snapshot_ref)
with session_factory.create_session() as session:

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from collections.abc import Iterable
from sqlalchemy import or_, select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
@ -15,20 +15,22 @@ from models.agent import (
AgentScope,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
WorkflowAgentNodeBinding,
)
from models.enums import AppStatus
from models.model import App
from models.model import App, AppMode
from models.workflow import Workflow
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.workspace_service import AgentWorkspaceService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
logger = logging.getLogger(__name__)
class WorkflowAgentRetirementService:
"""Archive workflow-only Agents once no effective binding owns them."""
"""Delete workflow-only Agent aggregates after their last Workflow owner is gone."""
@classmethod
def retire_unowned(
@ -37,13 +39,25 @@ class WorkflowAgentRetirementService:
tenant_id: str,
agent_ids: Iterable[str],
account_id: str | None,
) -> tuple[list[str], list[str]]:
"""Re-check ownership, archive orphans, and commit their resource retirement."""
) -> None:
"""Retire unowned workflow-only Agents in an independent transaction.
This method returns ``None``. It archives orphan Agents, retires their
working resources, and deletes their hidden Apps before committing. It
then publishes every hidden-App cleanup before publishing the Agent
resource collector; database and task-publication errors propagate.
Archived Agents, missing hidden App rows, and already-retired resources
remain cleanup candidates. A retry can therefore publish duplicate
cleanup tasks, which are expected to be idempotent.
"""
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
if not candidates:
return [], []
return
backing_app_ids: list[str] = []
retired_bindings: list[str] = []
retired_workspaces: list[str] = []
retired_snapshots: list[str] = []
try:
with session_factory.create_session() as session:
@ -53,22 +67,43 @@ class WorkflowAgentRetirementService:
agent_ids=candidates,
account_id=account_id,
)
retired_agents = session.scalars(
select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id.in_(retired_agent_ids),
)
).all()
backing_app_ids = sorted({agent.backing_app_id for agent in retired_agents if agent.backing_app_id})
for app_id in backing_app_ids:
AgentWorkspaceService.retire_all_for_app(
session=session,
tenant_id=tenant_id,
app_id=app_id,
)
retired_workspaces.extend(
session.scalars(
select(AgentWorkspace.id).where(
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.app_id == app_id,
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
)
).all()
)
for agent_id in retired_agent_ids:
bindings = session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.agent_id == agent_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding.id,
)
if binding_id is not None:
retired_bindings.append(binding_id)
if binding.status == AgentWorkingResourceStatus.ACTIVE:
AgentWorkspaceService.retire_binding(
session=session,
tenant_id=tenant_id,
binding_id=binding.id,
)
retired_bindings.append(binding.id)
retired_snapshots.extend(
AgentHomeSnapshotService.retire_all_for_agent(
session=session,
@ -76,6 +111,14 @@ class WorkflowAgentRetirementService:
agent_id=agent_id,
)
)
if backing_app_ids:
session.execute(
delete(App).where(
App.tenant_id == tenant_id,
App.id.in_(backing_app_ids),
App.mode == AppMode.AGENT,
)
)
session.commit()
except Exception:
logger.exception(
@ -85,8 +128,24 @@ class WorkflowAgentRetirementService:
"agent_ids": candidates,
},
)
return [], []
return retired_bindings, retired_snapshots
raise
for app_id in backing_app_ids:
try:
remove_app_and_related_data_task.delay(tenant_id=tenant_id, app_id=app_id)
except Exception:
logger.exception(
"Failed to enqueue hidden Agent App cleanup",
extra={"tenant_id": tenant_id, "app_id": app_id},
)
raise
enqueue_agent_resource_collection(
tenant_id=tenant_id,
workspace_ids=retired_workspaces,
binding_ids=retired_bindings,
home_snapshot_ids=retired_snapshots,
purge_agent_ids=retired_agent_ids,
)
@classmethod
def archive_unowned(
@ -97,7 +156,7 @@ class WorkflowAgentRetirementService:
agent_ids: Iterable[str],
account_id: str | None,
) -> list[str]:
"""Archive active orphans and return every orphan eligible for Home cleanup."""
"""Archive active orphans and return complete aggregate purge candidates."""
candidates = tuple(sorted({agent_id for agent_id in agent_ids if agent_id}))
if not candidates:
return []
@ -109,7 +168,7 @@ class WorkflowAgentRetirementService:
Agent.status.in_((AgentStatus.ACTIVE, AgentStatus.ARCHIVED)),
)
).all()
effective_agent_ids = cls._effective_agent_ids(
retained_agent_ids = cls.retained_agent_ids(
session=session,
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
@ -117,7 +176,7 @@ class WorkflowAgentRetirementService:
now = naive_utc_now()
cleanup_candidates: list[str] = []
for agent in agents:
if agent.id in effective_agent_ids:
if agent.id in retained_agent_ids:
continue
if agent.status == AgentStatus.ACTIVE:
agent.status = AgentStatus.ARCHIVED
@ -130,33 +189,32 @@ class WorkflowAgentRetirementService:
return cleanup_candidates
@staticmethod
def _effective_agent_ids(
def retained_agent_ids(
*,
session: Session,
tenant_id: str,
agent_ids: list[str],
) -> set[str]:
"""Return Agents that still have an exact persisted Workflow owner.
The owner key is tenant, App, Workflow, and Workflow version. Draft and
every published version, whether current or historical, count equally;
the App's current-Workflow pointer is not part of ownership.
"""
if not agent_ids:
return set()
values = session.scalars(
select(WorkflowAgentNodeBinding.agent_id)
.join(
Workflow,
Workflow.id == WorkflowAgentNodeBinding.workflow_id,
(Workflow.tenant_id == WorkflowAgentNodeBinding.tenant_id)
& (Workflow.app_id == WorkflowAgentNodeBinding.app_id)
& (Workflow.id == WorkflowAgentNodeBinding.workflow_id)
& (Workflow.version == WorkflowAgentNodeBinding.workflow_version),
)
.join(App, App.id == WorkflowAgentNodeBinding.app_id)
.where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.agent_id.in_(agent_ids),
Workflow.tenant_id == tenant_id,
Workflow.app_id == WorkflowAgentNodeBinding.app_id,
Workflow.version == WorkflowAgentNodeBinding.workflow_version,
App.tenant_id == tenant_id,
App.status == AppStatus.NORMAL,
or_(
Workflow.version == Workflow.VERSION_DRAFT,
App.workflow_id == Workflow.id,
),
)
.distinct()
).all()

View File

@ -23,8 +23,6 @@ from models.agent import (
AgentScope,
AgentSource,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
@ -42,7 +40,6 @@ from services.agent.errors import (
AgentNotFoundError,
AgentVersionNotFoundError,
)
from services.agent.home_snapshot_service import AgentHomeSnapshotService
from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService, WorkspaceOwnerScope
from services.app_service import AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
@ -1261,41 +1258,6 @@ class AgentRosterService:
raise AgentNameConflictError() from exc
return self.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent_id)
def archive_roster_agent(self, *, tenant_id: str, agent_id: str, account_id: str) -> None:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
retired_binding_ids: list[str] = []
if agent.status != AgentStatus.ARCHIVED:
agent.status = AgentStatus.ARCHIVED
agent.archived_by = account_id
agent.archived_at = naive_utc_now()
agent.updated_by = account_id
bindings = self._session.scalars(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.agent_id == agent_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
retired_id = AgentWorkspaceService.retire_binding(
session=self._session,
tenant_id=tenant_id,
binding_id=binding.id,
)
if retired_id is not None:
retired_binding_ids.append(retired_id)
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
session=self._session,
tenant_id=tenant_id,
agent_id=agent_id,
)
self._session.commit()
enqueue_agent_resource_collection(
tenant_id=tenant_id,
binding_ids=retired_binding_ids,
home_snapshot_ids=retired_snapshot_ids,
)
@staticmethod
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
if agent.source == AgentSource.AGENT_APP or (

View File

@ -24,7 +24,6 @@ from models.agent_config_entities import (
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
from models.model import App
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.prompt_mentions import (
@ -575,32 +574,18 @@ class WorkflowAgentPublishService:
session: Session,
draft_workflow: Workflow,
published_workflow: Workflow,
) -> set[str]:
current_workflow_id = session.scalar(
select(App.workflow_id).where(
App.tenant_id == draft_workflow.tenant_id,
App.id == draft_workflow.app_id,
)
)
retirement_candidates: set[str] = set()
if current_workflow_id:
retirement_candidates = {
agent_id
for agent_id in session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == current_workflow_id,
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
)
).all()
if agent_id
}
) -> None:
"""Copy all draft Roster and inline bindings to a published version.
Only copied inline bindings add owners for workflow-only Agents.
Publishing does not release existing draft or historical inline owners,
produces no retirement candidates, and returns ``None``.
"""
node_ids = {
node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)
}
if not node_ids:
return retirement_candidates
return
bindings = session.scalars(
select(WorkflowAgentNodeBinding).where(
@ -612,25 +597,17 @@ class WorkflowAgentPublishService:
)
).all()
if not bindings:
return retirement_candidates
agents_by_id = {
agent.id: agent
for agent in session.scalars(
select(Agent).where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id.in_({binding.agent_id for binding in bindings if binding.agent_id}),
)
).all()
}
return
for binding in bindings:
agent = agents_by_id.get(binding.agent_id) if binding.agent_id else None
current_snapshot_id = (
agent.active_config_snapshot_id
if agent is not None and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
current_snapshot_id = binding.current_snapshot_id
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT and binding.agent_id:
_, current_snapshot_id = cls._resolve_roster_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=binding.node_id,
agent_id=binding.agent_id,
)
copied = WorkflowAgentNodeBinding(
tenant_id=binding.tenant_id,
app_id=binding.app_id,
@ -645,7 +622,6 @@ class WorkflowAgentPublishService:
updated_by=binding.updated_by,
)
session.add(copied)
return retirement_candidates
@classmethod
def restore_agent_node_bindings_to_draft(
@ -671,9 +647,6 @@ class WorkflowAgentPublishService:
for binding in existing
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
}
for binding in existing:
session.delete(binding)
source_bindings = session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == source_workflow.tenant_id,
@ -682,6 +655,19 @@ class WorkflowAgentPublishService:
WorkflowAgentNodeBinding.workflow_version == source_workflow.version,
)
).all()
for source in source_bindings:
if source.binding_type == WorkflowAgentBindingType.ROSTER_AGENT and source.agent_id:
cls._resolve_roster_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=source.node_id,
agent_id=source.agent_id,
)
for binding in existing:
session.delete(binding)
session.flush()
for source in source_bindings:
agent_id = source.agent_id
snapshot_id = source.current_snapshot_id

View File

@ -13,7 +13,7 @@ from dataclasses import dataclass
from dify_agent.client import Client
from dify_agent.protocol import CreateExecutionBindingRequest, DestroyExecutionBindingRequest
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from clients.agent_backend.factory import create_agent_backend_client
@ -364,46 +364,68 @@ class AgentWorkspaceService:
.order_by(AgentWorkspaceBinding.created_at)
).all()
if not bindings:
logger.error(
"RETIRED Workspace has no Binding available for physical collection",
extra={"tenant_id": tenant_id, "workspace_id": workspace_id},
raise AgentWorkspaceError(
f"RETIRED Workspace has no RETIRED Binding: tenant_id={tenant_id}, workspace_id={workspace_id}"
)
return
anchor = bindings[0]
remaining_ids = [binding.id for binding in bindings[1:]]
remaining = [(binding.id, binding.backend_binding_ref) for binding in bindings[1:]]
workspace_ref = workspace.backend_workspace_ref
binding_ref = anchor.backend_binding_ref
anchor_id = anchor.id
failures: list[str] = []
first_error: Exception | None = None
with cls._client() as client:
client.destroy_execution_binding_sync(
DestroyExecutionBindingRequest(
binding_ref=binding_ref,
workspace_ref=workspace_ref,
destroy_workspace=True,
targets = [(anchor_id, binding_ref, workspace_ref, True)] + [
(binding_id, backend_binding_ref, None, False) for binding_id, backend_binding_ref in remaining
]
for binding_id, backend_binding_ref, target_workspace_ref, destroy_workspace in targets:
try:
client.destroy_execution_binding_sync(
DestroyExecutionBindingRequest(
binding_ref=backend_binding_ref,
workspace_ref=target_workspace_ref,
destroy_workspace=destroy_workspace,
)
)
except Exception as exc:
failures.append(binding_id)
if first_error is None:
first_error = exc
logger.exception(
"Failed to destroy retired Agent Workspace Binding",
extra={
"tenant_id": tenant_id,
"workspace_id": workspace_id,
"binding_id": binding_id,
"destroy_workspace": destroy_workspace,
},
)
if failures:
if len(failures) == 1 and first_error is not None:
raise first_error
raise AgentWorkspaceError(
f"Failed to destroy {len(failures)} RETIRED Workspace Binding(s): {', '.join(failures)}"
) from first_error
binding_ids = [anchor_id, *(binding_id for binding_id, _binding_ref in remaining)]
with session_factory.create_session() as session:
session.execute(
delete(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id.in_(binding_ids),
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.workspace_id == workspace_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
)
with session_factory.create_session() as session:
stored_workspace = session.scalar(
select(AgentWorkspace).where(
session.execute(
delete(AgentWorkspace).where(
AgentWorkspace.id == workspace_id,
AgentWorkspace.tenant_id == tenant_id,
AgentWorkspace.status == AgentWorkingResourceStatus.RETIRED,
)
)
stored_anchor = session.scalar(
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.id == anchor_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.RETIRED,
)
)
if stored_workspace is not None:
session.delete(stored_workspace)
if stored_anchor is not None:
session.delete(stored_anchor)
session.commit()
for remaining_id in remaining_ids:
cls.collect_retired_binding(tenant_id=tenant_id, binding_id=remaining_id)
@staticmethod
def validate_binding_generation(

View File

@ -476,6 +476,7 @@ def _default_client_factory() -> Client:
return create_agent_backend_client(
base_url=base_url,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
binding_file_download_timeout=dify_config.AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS,
)

View File

@ -41,6 +41,7 @@ class AppSiteConfiguration(NamedTuple):
input_placeholder: str | None
custom_disclaimer: str | None
default_language: str
prompt_public: bool
show_workflow_steps: bool
use_icon_as_answer_icon: bool

View File

@ -19,7 +19,7 @@ from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.file import remote_fetcher
from core.plugin.entities.plugin import PluginDependency
from core.rbac import RBACPermission
from core.rbac import RBACPermission, RBACResourceScope
from core.trigger.constants import (
TRIGGER_PLUGIN_NODE_TYPE,
TRIGGER_SCHEDULE_NODE_TYPE,
@ -65,7 +65,6 @@ from services.errors.app import WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -228,9 +227,7 @@ class AppDslService:
# If app_id is provided, check if it exists
app = None
if app_id:
stmt = select(App).where(App.id == app_id, App.tenant_id == account.current_tenant_id)
app = self._session.scalar(stmt)
app = self._load_app_for_overwrite(account, app_id)
if not app:
return Import(
id=import_id,
@ -368,8 +365,13 @@ class AppDslService:
app = None
if pending_data.app_id:
stmt = select(App).where(App.id == pending_data.app_id, App.tenant_id == account.current_tenant_id)
app = self._session.scalar(stmt)
app = self._load_app_for_overwrite(account, pending_data.app_id)
if not app:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="App not found",
)
# Create or update app
app = self._create_or_update_app(
@ -430,6 +432,31 @@ class AppDslService:
leaked_dependencies=leaked_dependencies,
)
def _load_app_for_overwrite(self, account: Account, app_id: str) -> App | None:
if account.current_tenant_id is None:
raise ValueError("Current tenant is not set")
if dify_config.RBAC_ENABLED and self._session.in_transaction():
raise RuntimeError("App overwrite authorization requires a session without an active transaction")
rbac_allowed = not dify_config.RBAC_ENABLED or RBACService.CheckAccess.check(
account.current_tenant_id,
account.id,
scene=RBACPermission.APP_IMPORT_EXPORT_DSL,
resource_type=RBACResourceScope.APP,
resource_id=app_id,
)
app = self._session.scalar(
select(App)
.where(
App.id == app_id,
App.tenant_id == account.current_tenant_id,
App.status == "normal",
)
.execution_options(populate_existing=True)
)
if app is not None and not rbac_allowed and app.maintainer != account.id:
raise NoPermissionError("You do not have permission to overwrite this app")
return app
@staticmethod
def _ensure_agent_manage_permission(account: Account) -> None:
"""Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``."""
@ -601,16 +628,11 @@ class AppDslService:
draft_workflow=draft_workflow,
)
self._session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
case AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
# Initialize model config
model_config = data.get("model_config")

View File

@ -7,7 +7,7 @@ from typing import Any, Literal, NotRequired, TypedDict, cast, override
import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy import ColumnElement, select
from sqlalchemy import ColumnElement, delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -36,6 +36,8 @@ from models.agent import (
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspaceBinding,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config
from models.workflow import Workflow
@ -287,6 +289,13 @@ class AppService:
) -> App | None:
return session.get(App, app_id)
@staticmethod
def get_normal_app_by_id(
app_id: str,
session: Session,
) -> App | None:
return session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
@staticmethod
def get_visible_app_by_id(
app_id: str,
@ -741,7 +750,7 @@ class AppService:
role: NotRequired[str | None]
@staticmethod
def _get_backing_agent_for_update(app: App, *, session: Session) -> Agent | None:
def _get_backing_agent(app: App, *, session: Session) -> Agent | None:
if app.mode != AppMode.AGENT:
return None
return session.scalar(
@ -784,7 +793,7 @@ class AppService:
Role omission is intentional: ``role=None`` preserves the backing
Agent's current role, while ``role=""`` explicitly clears it.
"""
agent = self._get_backing_agent_for_update(app, session=session)
agent = self._get_backing_agent(app, session=session)
if agent is None:
return
@ -988,21 +997,49 @@ class AppService:
return app
def delete_app(self, app: App, *, session: Session) -> None:
"""
Delete app
:param app: App instance
"""Delete an App and commit the passed session.
The transaction releases all of a Workflow App's binding owners across
draft and published versions, archives a backing Roster Agent, retires
its resources, and deletes the App. Deleting a Roster Agent's backing
App does not remove bindings owned by external Workflows.
After commit, the main App cleanup is published first, followed by
workflow-only Agent retirement and the Roster resource collector. Any
publication failure propagates.
"""
app_was_deleted.send(app)
backing_agent = self._get_backing_agent_for_update(app, session=session)
workflow_agent_ids = session.scalars(
select(Agent.id).where(
Agent.tenant_id == app.tenant_id,
Agent.app_id == app.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.status == AgentStatus.ACTIVE,
backing_agent = self._get_backing_agent(app, session=session)
workflow_agent_ids = set(
session.scalars(
select(Agent.id).where(
Agent.tenant_id == app.tenant_id,
Agent.app_id == app.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.status == AgentStatus.ACTIVE,
)
).all()
)
if app.mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT):
workflow_agent_ids.update(
agent_id
for agent_id in session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
WorkflowAgentNodeBinding.app_id == app.id,
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
WorkflowAgentNodeBinding.agent_id.is_not(None),
)
).all()
if agent_id
)
session.execute(
delete(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
WorkflowAgentNodeBinding.app_id == app.id,
)
)
).all()
account_id = current_user.id if current_user else None
if backing_agent is not None:
now = naive_utc_now()
@ -1019,17 +1056,16 @@ class AppService:
select(AgentWorkspaceBinding).where(
AgentWorkspaceBinding.tenant_id == app.tenant_id,
AgentWorkspaceBinding.agent_id == backing_agent.id,
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
)
).all()
for binding in bindings:
binding_id = AgentWorkspaceService.retire_binding(
session=session,
tenant_id=app.tenant_id,
binding_id=binding.id,
)
if binding_id is not None:
retired_binding_ids.append(binding_id)
if binding.status == AgentWorkingResourceStatus.ACTIVE:
AgentWorkspaceService.retire_binding(
session=session,
tenant_id=app.tenant_id,
binding_id=binding.id,
)
retired_binding_ids.append(binding.id)
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
session=session,
tenant_id=app.tenant_id,
@ -1044,7 +1080,16 @@ class AppService:
session.delete(app)
session.commit()
workflow_binding_ids, workflow_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
try:
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
except Exception:
logger.exception(
"Failed to enqueue App cleanup",
extra={"tenant_id": app.tenant_id, "app_id": app.id},
)
raise
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app.tenant_id,
agent_ids=workflow_agent_ids,
account_id=account_id,
@ -1052,8 +1097,9 @@ class AppService:
enqueue_agent_resource_collection(
tenant_id=app.tenant_id,
workspace_ids=retired_workspace_ids,
binding_ids=[*retired_binding_ids, *workflow_binding_ids],
home_snapshot_ids=[*retired_snapshot_ids, *workflow_snapshot_ids],
binding_ids=retired_binding_ids,
home_snapshot_ids=retired_snapshot_ids,
purge_agent_ids=[backing_agent.id] if backing_agent is not None else [],
)
# clean up web app settings
@ -1063,9 +1109,6 @@ class AppService:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(app.tenant_id)
# Trigger asynchronous deletion of app and related data
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
@staticmethod
def get_app_code_by_id(app_id: str, *, session: Session) -> str:
"""

View File

@ -28,6 +28,9 @@ _http_client: httpx.Client = get_pooled_http_client(
)
EmailFreezeType = Literal["freeze", "email_domain_suspended"]
class SubscriptionPlan(TypedDict):
"""Tenant subscriptionplan information."""
@ -479,13 +482,26 @@ class BillingService:
return cls._send_request("DELETE", "/account", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:
def get_email_freeze_type(cls, email: str) -> EmailFreezeType | None:
params = {"email": email}
try:
response = cls._send_request("GET", "/account/in-freeze", params=params)
return bool(response.get("data", False))
if not response.get("data", False):
return None
freeze_type = response.get("freeze_type") or response.get("freezeType")
if freeze_type in ("freeze", "email_domain_suspended"):
return freeze_type
# Keep compatibility with older billing services that only return
# the boolean `data` field.
return "freeze"
except Exception:
return False
return None
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:
return cls.get_email_freeze_type(email) is not None
@classmethod
def update_account_deletion_feedback(cls, email: str, feedback: str):

View File

@ -18,6 +18,7 @@ import yaml
from sqlalchemy import or_
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
from extensions.ext_database import db
@ -26,7 +27,6 @@ from models import Account, ApiToken, Tenant, TenantAccountJoin, TenantAccountRo
from models.enums import ApiTokenType
from models.model import App
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.app_dsl_service import AppDslService
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
from services.data_migration.entities import (
@ -48,7 +48,6 @@ from services.tools.api_tools_manage_service import ApiToolManageService
from services.tools.mcp_tools_manage_service import MCPToolManageService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
from services.workflow_service import WorkflowService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@dataclass(frozen=True)
@ -326,11 +325,14 @@ class MigrationImportService:
) -> str:
import_service = AppDslService(session)
if existing_app is not None:
existing_app_id = existing_app.id
if dify_config.RBAC_ENABLED:
session.commit()
import_result = import_service.import_app(
account=account,
import_mode="yaml-content",
yaml_content=dsl_content,
app_id=existing_app.id,
app_id=existing_app_id,
)
else:
import_app_id = app_id if self._should_preserve_source_app_id(options) else None
@ -713,7 +715,7 @@ class MigrationImportService:
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
if account_in_session is None:
raise MigrationDataError(f"Operator account not found: {account.id}")
workflow, retirement_candidates = workflow_service.publish_workflow(
workflow = workflow_service.publish_workflow(
session=session,
app_model=app_in_session,
account=account_in_session,
@ -723,16 +725,6 @@ class MigrationImportService:
app_in_session.workflow_id = workflow.id
app_in_session.updated_by = account.id
app_in_session.updated_at = naive_utc_now()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
tenant_id=target.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=target.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
def _import_mcp_tools(
self,

View File

@ -0,0 +1,42 @@
"""Framework-neutral contracts for Console account use cases."""
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True, slots=True)
class AccountSnapshot:
id: str
name: str
email: str
avatar: str | None
is_password_set: bool
interface_language: str | None
interface_theme: str | None
timezone: str | None
last_login_at: datetime | None
last_login_ip: str | None
status: str
initialized_at: datetime | None
created_at: datetime
@dataclass(frozen=True, slots=True)
class AccountProfileChanges:
name: str | None = None
avatar: str | None = None
interface_language: str | None = None
interface_theme: str | None = None
timezone: str | None = None
def has_changes(self) -> bool:
return any(
value is not None
for value in (
self.name,
self.avatar,
self.interface_language,
self.interface_theme,
self.timezone,
)
)

View File

@ -9,6 +9,11 @@ class AccountRegisterError(BaseServiceError):
pass
class EmailDomainSuspendedError(AccountRegisterError):
def __init__(self, description: str = "This email domain has been suspended."):
super().__init__(description)
class AccountLoginError(BaseServiceError):
pass

View File

@ -163,6 +163,10 @@ class FeatureService:
def is_webapp_auth_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@staticmethod
def is_trial_app_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN

View File

@ -20,6 +20,7 @@ from constants import (
VIDEO_EXTENSIONS,
)
from core.rag.extractor.extract_processor import ExtractProcessor
from enums import DeploymentEdition
from extensions.ext_storage import storage
from extensions.storage.storage_type import StorageType
from graphon.file import helpers as file_helpers
@ -179,6 +180,13 @@ class FileService:
content_type=content_type,
)
def get_icon_url(self, file_id: str, tenant_id: str) -> str:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
):
return self.get_file_presigned_url(file_id=file_id, tenant_id=tenant_id)
return file_helpers.get_signed_file_url(upload_file_id=file_id)
def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile:
if len(text_name) > 200:
text_name = text_name[:200]

View File

@ -301,7 +301,11 @@ class RagPipelineService:
return workflow
def get_published_workflow_by_id(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
"""Fetch a published workflow snapshot by ID for restore operations."""
"""Fetch and lock a published Workflow snapshot for restoration.
The source lock is held until the service transaction ends, preventing
concurrent deletion while restore copies its Workflow snapshot fields.
"""
workflow = self._session.scalar(
select(Workflow)
.where(
@ -310,6 +314,7 @@ class RagPipelineService:
Workflow.id == workflow_id,
)
.limit(1)
.with_for_update()
)
if workflow and workflow.version == Workflow.VERSION_DRAFT:
raise IsDraftWorkflowError("source workflow must be published")
@ -419,7 +424,8 @@ class RagPipelineService:
Pipelines reuse the shared draft-restore field copy helper, but still own
the pipeline-specific flush/link step that wires a newly created draft
back onto ``pipeline.workflow_id``.
back onto ``pipeline.workflow_id``. The source version remains locked
through snapshot-field copy and commit.
"""
source_workflow = self.get_published_workflow_by_id(pipeline=pipeline, workflow_id=workflow_id)
if not source_workflow:

View File

@ -1,76 +0,0 @@
import json
from os import path
from pathlib import Path
from typing import Any, override
from flask import current_app
from sqlalchemy.orm import Session
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from buildin, the location is constants/recommended_apps.json
"""
builtin_data: dict[str, Any] | None = None
@override
def get_type(self) -> str:
return RecommendAppType.BUILDIN
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
del session
result = self.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session):
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session):
del session
result = self.fetch_recommended_app_detail_from_builtin(app_id)
return result
@classmethod
def _get_builtin_data(cls):
"""
Get builtin data.
:return:
"""
if cls.builtin_data:
return cls.builtin_data
root_path = current_app.root_path
cls.builtin_data = json.loads(
Path(path.join(root_path, "constants", "recommended_apps.json")).read_text(encoding="utf-8")
)
return cls.builtin_data or {}
@classmethod
def fetch_recommended_apps_from_builtin(cls, language: str):
"""
Fetch recommended apps from builtin.
:param language: language
:return:
"""
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
return builtin_data.get("recommended_apps", {}).get(language, {})
@classmethod
def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None:
"""
Fetch recommended app detail from builtin.
:param app_id: App ID
:return:
"""
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
return builtin_data.get("app_details", {}).get(app_id)

View File

@ -1,49 +0,0 @@
"""Apply Redis-backed category ordering for DB-backed Explore apps."""
import json
import logging
from collections.abc import Collection
from typing import Any
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
def _category_order_key(language: str) -> str:
return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}"
def get_explore_app_category_order(language: str) -> list[str]:
try:
raw_categories = redis_client.get(_category_order_key(language))
except Exception:
logger.exception("Failed to read explore app category order from Redis.")
return []
if not raw_categories:
return []
if isinstance(raw_categories, bytes):
raw_categories = raw_categories.decode("utf-8")
try:
categories: Any = json.loads(raw_categories)
except (TypeError, json.JSONDecodeError):
logger.warning("Invalid explore app category order payload for language %s.", language)
return []
if not isinstance(categories, list):
return []
return [category for category in categories if isinstance(category, str)]
def order_categories(categories: Collection[str], language: str) -> list[str]:
configured_order = get_explore_app_category_order(language)
if configured_order:
return configured_order
return sorted(categories)

View File

@ -1,175 +0,0 @@
from typing import Any, NotRequired, TypedDict, override
from sqlalchemy import select
from sqlalchemy.orm import Session
from constants.languages import languages
from models.model import App, RecommendedApp
from services.app_dsl_service import AppDslService
from services.recommend_app.category_order import order_categories
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
class RecommendedAppItemDict(TypedDict):
id: str
app: App | None
app_id: str
description: Any
copyright: Any
privacy_policy: Any
custom_disclaimer: str
categories: list[str]
position: int
is_listed: bool
can_trial: NotRequired[bool]
class RecommendedAppsResultDict(TypedDict):
recommended_apps: list[RecommendedAppItemDict]
categories: list[str]
class RecommendedAppDetailDict(TypedDict):
id: str
name: str
icon: Any
icon_background: str | None
mode: str
export_data: str
class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from database
"""
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
result = self.fetch_recommended_apps_from_db(language, session=session)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
result = self.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
result = self.fetch_recommended_app_detail_from_db(app_id, session=session)
return result
@override
def get_type(self) -> str:
return RecommendAppType.DATABASE
@classmethod
def fetch_recommended_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
"""
Fetch recommended apps from db.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session)
if len(recommended_apps) == 0:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session)
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def fetch_learn_dify_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
"""
Fetch listed recommended apps explicitly marked for the Learn Dify section.
:param language: language
:return:
"""
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session, is_learn_dify=True)
if len(recommended_apps) == 0 and language != languages[0]:
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session, is_learn_dify=True)
return cls._format_recommended_apps(recommended_apps, language)
@classmethod
def _fetch_listed_recommended_apps(
cls, language: str, *, session: Session, is_learn_dify: bool | None = None
) -> list[RecommendedApp]:
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
if is_learn_dify is not None:
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
return list(session.scalars(select(RecommendedApp).where(*filters)).all())
@classmethod
def _format_recommended_apps(
cls, recommended_apps: list[RecommendedApp], language: str
) -> RecommendedAppsResultDict:
"""
Serialize DB recommended app rows into the Explore list response shape.
:param recommended_apps: recommended app rows
:param language: language used for category ordering
:return:
"""
categories = set()
recommended_apps_result: list[RecommendedAppItemDict] = []
for recommended_app in recommended_apps:
app = recommended_app.app
if not app or not app.is_public:
continue
site = app.site
if not site:
continue
app_categories = recommended_app.categories or []
recommended_app_result: RecommendedAppItemDict = {
"id": recommended_app.id,
"app": recommended_app.app,
"app_id": recommended_app.app_id,
"description": site.description,
"copyright": site.copyright,
"privacy_policy": site.privacy_policy,
"custom_disclaimer": site.custom_disclaimer,
"categories": app_categories,
"position": recommended_app.position,
"is_listed": recommended_app.is_listed,
}
recommended_apps_result.append(recommended_app_result)
categories.update(app_categories)
return RecommendedAppsResultDict(
recommended_apps=recommended_apps_result,
categories=order_categories(categories, language),
)
@classmethod
def fetch_recommended_app_detail_from_db(cls, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
"""
Fetch recommended app detail from db.
:param app_id: App ID
:return:
"""
# is in public recommended list
recommended_app = session.scalar(
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1)
)
if not recommended_app:
return None
# get app detail
app_model = session.get(App, app_id)
if not app_model or not app_model.is_public:
return None
return RecommendedAppDetailDict(
id=app_model.id,
name=app_model.name,
icon=app_model.icon,
icon_background=app_model.icon_background,
mode=app_model.mode,
export_data=AppDslService.export_dsl(app_model=app_model, session=session),
)

View File

@ -1,15 +0,0 @@
from typing import Any, Protocol
from sqlalchemy.orm import Session
class RecommendAppRetrievalBase(Protocol):
"""Interface for recommend app retrieval."""
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> Any: ...
def get_learn_dify_apps(self, language: str, *, session: Session) -> Any: ...
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> Any: ...
def get_type(self) -> str: ...

View File

@ -1,23 +0,0 @@
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval
class RecommendAppRetrievalFactory:
@staticmethod
def get_recommend_app_factory(mode: str) -> type[RecommendAppRetrievalBase]:
match mode:
case RecommendAppType.REMOTE:
return RemoteRecommendAppRetrieval
case RecommendAppType.DATABASE:
return DatabaseRecommendAppRetrieval
case RecommendAppType.BUILDIN:
return BuildInRecommendAppRetrieval
case _:
raise ValueError(f"invalid fetch recommended apps mode: {mode}")
@staticmethod
def get_buildin_recommend_app_retrieval():
return BuildInRecommendAppRetrieval

View File

@ -1,7 +0,0 @@
from enum import StrEnum
class RecommendAppType(StrEnum):
REMOTE = "remote"
BUILDIN = "builtin"
DATABASE = "db"

View File

@ -1,173 +0,0 @@
import logging
import threading
from typing import Any, override
import httpx
from cachetools import TTLCache
from flask import has_request_context, request
from sqlalchemy.orm import Session
from configs import dify_config
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
logger = logging.getLogger(__name__)
_REMOTE_FETCH_CACHE_MAXSIZE = 64
_remote_fetch_cache: TTLCache[tuple[str, str], dict[str, Any]] | None = None
_remote_fetch_cache_ttl: int | None = None
_remote_fetch_cache_lock = threading.Lock()
def _current_origin_headers() -> dict[str, str]:
origin = request.headers.get("Origin") if has_request_context() else None
if origin:
return {"Origin": origin}
console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "")
if not isinstance(console_web_url, str) or not console_web_url:
return {}
return {"Origin": console_web_url}
def _remote_fetch_cache_key(url: str, headers: dict[str, str]) -> tuple[str, str]:
return url, headers.get("Origin", "")
def _hosted_fetch_cache_ttl() -> int:
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
if isinstance(ttl, int) and not isinstance(ttl, bool):
return ttl
return 600
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], dict[str, Any]] | None:
ttl = _hosted_fetch_cache_ttl()
if ttl <= 0:
return None
global _remote_fetch_cache, _remote_fetch_cache_ttl
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
with _remote_fetch_cache_lock:
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
_remote_fetch_cache_ttl = ttl
return _remote_fetch_cache
def clear_remote_fetch_cache() -> None:
"""Reset the in-memory remote fetch cache (used by tests)."""
global _remote_fetch_cache, _remote_fetch_cache_ttl
with _remote_fetch_cache_lock:
_remote_fetch_cache = None
_remote_fetch_cache_ttl = None
def _fetch_remote_payload(url: str) -> tuple[int, dict[str, Any] | None]:
headers = _current_origin_headers()
cache_key = _remote_fetch_cache_key(url, headers)
cache = _get_remote_fetch_cache()
if cache is not None:
with _remote_fetch_cache_lock:
cached = cache.get(cache_key)
if cached is not None:
return 200, cached
response = httpx.get(url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0))
status_code = response.status_code
if status_code != 200:
return status_code, None
result: dict[str, Any] = response.json()
if cache is not None:
with _remote_fetch_cache_lock:
cache[cache_key] = result
return status_code, result
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from dify official.
The remote `/apps` payload is already curated for display, including category order.
Keep the response order intact so Explore matches the template service.
"""
@override
def get_recommend_app_detail(self, app_id: str, *, session: Session):
del session
try:
result = self.fetch_recommended_app_detail_from_dify_official(app_id)
except Exception as e:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", e)
result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin(app_id)
return result
@override
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
del session
try:
result = self.fetch_recommended_apps_from_dify_official(language)
except Exception as e:
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", e)
result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str, *, session: Session):
try:
result = self.fetch_learn_dify_apps_from_dify_official(language)
except Exception as e:
logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e)
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
return result
@override
def get_type(self) -> str:
return RecommendAppType.REMOTE
@classmethod
def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None:
"""
Fetch recommended app detail from dify official.
:param app_id: App ID
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/{app_id}"
status_code, data = _fetch_remote_payload(url)
if status_code != 200:
return None
return data
@classmethod
def fetch_recommended_apps_from_dify_official(cls, language: str):
"""
Fetch recommended apps from dify official.
:param language: language
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps?language={language}"
status_code, result = _fetch_remote_payload(url)
if status_code != 200:
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
return result
@classmethod
def fetch_learn_dify_apps_from_dify_official(cls, language: str):
"""
Fetch Learn Dify apps from dify official.
:param language: language
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/learn-dify?language={language}"
status_code, result = _fetch_remote_payload(url)
if status_code != 200:
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
return result

View File

@ -0,0 +1,376 @@
"""Typed remote and built-in adapters for the recommended app catalog."""
import json
import logging
import threading
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import cast, override
import httpx
from cachetools import TTLCache
from configs import dify_config
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppRecord,
)
logger = logging.getLogger(__name__)
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
_BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json"
_REMOTE_FETCH_CACHE_MAXSIZE = 64
_remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None
_remote_fetch_cache_ttl: int | None = None
_remote_fetch_cache_lock = threading.Lock()
def _hosted_fetch_cache_ttl() -> int:
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
if isinstance(ttl, int) and not isinstance(ttl, bool):
return ttl
return 600
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None:
ttl = _hosted_fetch_cache_ttl()
if ttl <= 0:
return None
global _remote_fetch_cache, _remote_fetch_cache_ttl
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
with _remote_fetch_cache_lock:
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
_remote_fetch_cache_ttl = ttl
return _remote_fetch_cache
def clear_remote_fetch_cache() -> None:
"""Reset the in-memory remote fetch cache (used by tests)."""
global _remote_fetch_cache, _remote_fetch_cache_ttl
with _remote_fetch_cache_lock:
_remote_fetch_cache = None
_remote_fetch_cache_ttl = None
class _RecommendedAppSourceUnavailableError(Exception):
pass
class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
def __init__(self) -> None:
self._data: Mapping[str, object] | None = None
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
return _map_recommended_page(self._raw_page(language))
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
return _map_learn_dify_page(self._raw_learn_dify_page(language))
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
detail = self._raw_detail(app_id)
if detail is None:
return None
return _map_detail(_as_mapping(detail, field="recommended app detail"))
@override
def contains(self, app_id: str) -> bool:
return self._raw_detail(app_id) is not None
def _raw_page(self, language: str) -> Mapping[str, object]:
pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps")
return _as_mapping(pages.get(language, {}), field="recommended app page")
def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]:
apps = self._raw_learn_dify_apps(language)
if not apps and language != _BUILTIN_FALLBACK_LANGUAGE:
apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE)
return {"recommended_apps": apps}
def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]:
page = self._raw_page(language)
return tuple(
app
for app in _as_sequence(page.get("recommended_apps", ()), field="apps")
if _as_mapping(app, field="recommended app").get("is_learn_dify") is True
)
def _raw_detail(self, app_id: str) -> object | None:
details = _as_mapping(self._get_data().get("app_details", {}), field="app_details")
return details.get(app_id)
def _get_data(self) -> Mapping[str, object]:
if self._data is None:
loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8"))
self._data = _as_mapping(loaded, field="built-in recommended app catalog")
return self._data
class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
result = self._fetch(lambda: self._fetch_page(language))
return _map_recommended_page(_as_mapping(result, field="recommended app page"))
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
result = self._fetch(lambda: self._fetch_learn_dify_page(language))
return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page"))
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
detail = self._fetch(lambda: self._fetch_detail(app_id))
if detail is None:
return None
return _map_detail(_as_mapping(detail, field="recommended app detail"))
@override
def contains(self, app_id: str) -> bool:
detail = self._fetch(lambda: self._fetch_detail(app_id))
return detail is not None
def _fetch_detail(self, app_id: str) -> object | None:
status_code, detail = self._get_payload(f"/apps/{app_id}")
if status_code != 200:
# Preserve the legacy detail contract: only request or decoding
# failures use the bundled fallback; HTTP responses are authoritative.
return None
return detail
def _fetch_page(self, language: str) -> object:
status_code, page = self._get_payload(f"/apps?language={language}")
if status_code != 200:
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
return page
def _fetch_learn_dify_page(self, language: str) -> object:
status_code, page = self._get_payload(f"/apps/learn-dify?language={language}")
if status_code != 200:
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
return page
@staticmethod
def _get_payload(path: str) -> tuple[int, object]:
origin = dify_config.CONSOLE_WEB_URL
url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}"
headers = {"Origin": origin} if origin else {}
cache_key = (url, origin)
cache = _get_remote_fetch_cache()
if cache is not None:
with _remote_fetch_cache_lock:
cached = cache.get(cache_key)
if cached is not None:
return 200, cached
response = httpx.get(
url,
headers=headers,
timeout=httpx.Timeout(10.0, connect=3.0),
)
if response.status_code != 200:
return response.status_code, None
result = response.json()
if cache is not None:
with _remote_fetch_cache_lock:
cache[cache_key] = result
return response.status_code, result
@staticmethod
def _fetch[T](fetch: Callable[[], T]) -> T:
try:
return fetch()
except Exception as error:
raise _RecommendedAppSourceUnavailableError(str(error)) from error
class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery):
def __init__(
self,
*,
remote: RecommendedAppCatalogQuery,
database: RecommendedAppCatalogQuery,
builtin: RecommendedAppCatalogQuery,
) -> None:
self._remote = remote
self._builtin = builtin
self._sources: dict[str, RecommendedAppCatalogQuery] = {
"remote": remote,
"db": database,
"builtin": builtin,
}
@override
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
source = self._source()
if source is not self._remote:
page = source.list_recommended(language)
else:
try:
page = self._remote.list_recommended(language)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error)
page = self._builtin.list_recommended(language)
if not page.recommended_apps:
return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE)
return page
@override
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
source = self._source()
if source is not self._remote:
return source.list_learn_dify(language)
try:
return self._remote.list_learn_dify(language)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error)
return self._builtin.list_learn_dify(language)
@override
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
source = self._source()
if source is not self._remote:
return source.get_detail(app_id)
try:
return self._remote.get_detail(app_id)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
return self._builtin.get_detail(app_id)
@override
def contains(self, app_id: str) -> bool:
source = self._source()
if source is not self._remote:
return source.contains(app_id)
try:
return self._remote.contains(app_id)
except _RecommendedAppSourceUnavailableError as error:
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
return self._builtin.contains(app_id)
def _source(self) -> RecommendedAppCatalogQuery:
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
try:
return self._sources[mode]
except KeyError:
raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None
def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
if not source.get("recommended_apps"):
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
return _map_page(source)
def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
return RecommendedAppCatalogPage(
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
categories=_as_string_tuple(source["categories"], field="categories"),
)
def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
return RecommendedAppCatalogPage(
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
categories=(),
)
def _map_app(source: object) -> RecommendedAppRecord:
source = _as_mapping(source, field="recommended app")
app_id = source["app_id"]
if not isinstance(app_id, str):
raise TypeError("app_id must be a string")
app_source = source.get("app")
if app_source is not None:
app_source = _as_mapping(app_source, field="app")
return RecommendedAppRecord(
app=_map_app_info(app_source),
app_id=app_id,
description=cast(str | None, source.get("description")),
copyright=cast(str | None, source.get("copyright")),
privacy_policy=cast(str | None, source.get("privacy_policy")),
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
categories=_as_string_tuple(source.get("categories", ()), field="categories"),
position=cast(int | None, source.get("position")),
is_listed=cast(bool | None, source.get("is_listed")),
)
def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None:
if source is None:
return None
app_id = source["id"]
if not isinstance(app_id, str):
raise TypeError("app.id must be a string")
return RecommendedAppInfoRecord(
id=app_id,
name=cast(str | None, source.get("name")),
mode=_enum_string(source.get("mode"), field="app.mode"),
icon=cast(str | None, source.get("icon")),
icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"),
icon_background=cast(str | None, source.get("icon_background")),
)
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = _enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
id=app_id,
name=name,
icon=cast(str | None, source.get("icon")),
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
)
def _as_mapping(value: object, *, field: str) -> Mapping[str, object]:
if not isinstance(value, Mapping):
raise TypeError(f"{field} must be a mapping")
return cast(Mapping[str, object], value)
def _as_sequence(value: object, *, field: str) -> Sequence[object]:
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
raise TypeError(f"{field} must be a sequence")
return cast(Sequence[object], value)
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
values = _as_sequence(value, field=field)
if not all(isinstance(item, str) for item in values):
raise TypeError(f"{field} must contain only strings")
return cast(tuple[str, ...], tuple(values))
def _enum_string(value: object, *, field: str) -> str | None:
if value is None:
return None
if isinstance(value, str):
return str(value)
raise TypeError(f"{field} must be a string or string enum")

View File

@ -0,0 +1,186 @@
"""Application service for querying the recommended app catalog."""
from collections.abc import Sequence, Set
from typing import NamedTuple, Protocol
from constants.languages import languages
class RecommendedAppInfoRecord(NamedTuple):
id: str
name: str | None
mode: str | None
icon: str | None
icon_type: str | None
icon_background: str | None
class RecommendedAppRecord(NamedTuple):
app: RecommendedAppInfoRecord | None
app_id: str
description: str | None
copyright: str | None
privacy_policy: str | None
custom_disclaimer: str | None
categories: tuple[str, ...]
position: int | None
is_listed: bool | None
class RecommendedAppCatalogPage(NamedTuple):
recommended_apps: tuple[RecommendedAppRecord, ...]
categories: tuple[str, ...]
class RecommendedAppDetailRecord(NamedTuple):
id: str
name: str
icon: str | None
icon_background: str | None
mode: str
export_data: str
class RecommendedAppCatalogQuery(Protocol):
"""Read from the recommended-app catalog."""
def list_recommended(self, language: str) -> RecommendedAppCatalogPage: ...
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: ...
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: ...
def contains(self, app_id: str) -> bool: ...
class TrialAppQuery(Protocol):
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]: ...
class RecommendedAppSummary(NamedTuple):
app: RecommendedAppInfoRecord | None
app_id: str
description: str | None
copyright: str | None
privacy_policy: str | None
custom_disclaimer: str | None
categories: tuple[str, ...]
position: int | None
is_listed: bool | None
can_trial: bool
class RecommendedAppListResult(NamedTuple):
recommended_apps: tuple[RecommendedAppSummary, ...]
categories: tuple[str, ...]
class LearnDifyAppListResult(NamedTuple):
recommended_apps: tuple[RecommendedAppSummary, ...]
class RecommendedAppDetailSummary(NamedTuple):
id: str
name: str
icon: str | None
icon_background: str | None
mode: str
export_data: str
can_trial: bool
class RecommendedAppNotFoundError(Exception):
pass
class RecommendedAppQueryService:
def __init__(
self,
*,
catalog: RecommendedAppCatalogQuery,
trial_apps: TrialAppQuery,
trial_enabled: bool,
) -> None:
self._catalog = catalog
self._trial_apps = trial_apps
self._trial_enabled = trial_enabled
def is_trial_enabled(self) -> bool:
return self._trial_enabled
def is_previewable(self, app_id: str) -> bool:
if app_id in self._trial_apps.existing_ids((app_id,)):
return True
return self._catalog.contains(app_id)
def list_recommended(
self,
*,
requested_language: str | None,
interface_language: str | None,
) -> RecommendedAppListResult:
language = self._resolve_language(requested_language, interface_language)
page = self._catalog.list_recommended(language)
return RecommendedAppListResult(
recommended_apps=self._with_trial_status(page.recommended_apps),
categories=page.categories,
)
def list_learn_dify(
self,
*,
requested_language: str | None,
interface_language: str | None,
) -> LearnDifyAppListResult:
language = self._resolve_language(requested_language, interface_language)
page = self._catalog.list_learn_dify(language)
return LearnDifyAppListResult(recommended_apps=self._with_trial_status(page.recommended_apps))
def get_detail(self, app_id: str) -> RecommendedAppDetailSummary:
detail = self._catalog.get_detail(app_id)
if detail is None:
raise RecommendedAppNotFoundError
can_trial = False
if self._trial_enabled:
can_trial = detail.id in self._trial_apps.existing_ids((detail.id,))
return RecommendedAppDetailSummary(
id=detail.id,
name=detail.name,
icon=detail.icon,
icon_background=detail.icon_background,
mode=detail.mode,
export_data=detail.export_data,
can_trial=can_trial,
)
def _with_trial_status(self, apps: Sequence[RecommendedAppRecord]) -> tuple[RecommendedAppSummary, ...]:
trial_app_ids: Set[str] = set()
if self._trial_enabled:
trial_app_ids = self._trial_apps.existing_ids([app.app_id for app in apps])
return tuple(
RecommendedAppSummary(
app=app.app,
app_id=app.app_id,
description=app.description,
copyright=app.copyright,
privacy_policy=app.privacy_policy,
custom_disclaimer=app.custom_disclaimer,
categories=app.categories,
position=app.position,
is_listed=app.is_listed,
can_trial=app.app_id in trial_app_ids,
)
for app in apps
)
@staticmethod
def _resolve_language(requested_language: str | None, interface_language: str | None) -> str:
if requested_language and requested_language in languages:
return requested_language
if interface_language:
return interface_language
return languages[0]

View File

@ -1,119 +0,0 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums import DeploymentEdition
from models.model import AccountTrialAppRecord, App, TrialApp
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
class RecommendedAppService:
"""Own recommended app retrieval and Cloud-only trial eligibility."""
@staticmethod
def is_trial_app_enabled() -> bool:
"""Return whether trial execution is enabled for this deployment."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def get_app(cls, app_id: str, *, session: Session) -> App | None:
"""Return a normal app only when it belongs to the recommended catalog."""
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
recommended_app_detail = retrieval_instance.get_recommend_app_detail(app_id, session=session)
if recommended_app_detail is None:
return None
return session.scalar(select(App).where(App.id == app_id, App.status == "normal").limit(1))
@classmethod
def get_recommended_apps_and_categories(cls, language: str, *, session: Session):
"""
Get recommended apps and categories.
:param language: language
:return:
"""
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result = retrieval_instance.get_recommended_apps_and_categories(language, session=session)
if not result.get("recommended_apps"):
result = (
RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval().fetch_recommended_apps_from_builtin(
"en-US"
)
)
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
return result
@classmethod
def get_learn_dify_apps(cls, language: str, *, session: Session) -> dict[str, Any]:
"""
Get recommended apps marked for the Learn Dify section.
:param language: language
:return:
"""
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result = retrieval_instance.get_learn_dify_apps(language, session=session)
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
return {"recommended_apps": apps}
@classmethod
def get_recommend_app_detail(cls, app_id: str, *, session: Session) -> dict[str, Any] | None:
"""
Get recommend app detail.
:param app_id: app id
:return:
"""
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id, session=session)
if result is None:
return None
result["can_trial"] = cls.is_trial_app_enabled() and cls._can_trial_app(session, result["id"])
return result
@classmethod
def add_trial_app_record(cls, app_id: str, account_id: str, *, session: Session):
"""
Add trial app record.
:param app_id: app id
:return:
"""
account_trial_app_record = session.scalar(
select(AccountTrialAppRecord)
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
.limit(1)
)
if account_trial_app_record:
account_trial_app_record.count += 1
session.commit()
else:
session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
session.commit()
@staticmethod
def _can_trial_app(session: Session, app_id: str) -> bool:
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
return trial_app_model is not None
@staticmethod
def _get_trial_app_ids(session: Session, app_ids: list[str]) -> set[str]:
if not app_ids:
return set()
return set(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())

View File

@ -32,7 +32,6 @@ from services.entities.dsl_entities import (
)
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -491,29 +490,34 @@ class SnippetDslService:
self._session.commit()
if workflow_data:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return snippet
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
def export_snippet_dsl(
self, snippet: CustomizedSnippet, include_secret: bool = False, workflow_id: str | None = None
) -> str:
"""
Export snippet as DSL
:param snippet: CustomizedSnippet instance
:param include_secret: Whether include secret variable
:param workflow_id: Optional published workflow version to export; defaults to the draft workflow
:return: YAML string
"""
snippet_service = self._snippet_service()
workflow = snippet_service.get_draft_workflow(snippet=snippet)
workflow = (
snippet_service.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
if workflow_id
else snippet_service.get_draft_workflow(snippet=snippet)
)
if not workflow:
raise ValueError("Missing draft workflow configuration, please check.")
workflow_description = (
f"published workflow {workflow_id}" if workflow_id else "draft workflow configuration"
)
raise ValueError(f"Missing {workflow_description}, please check.")
icon_info = snippet.icon_info or {}
export_data = {

View File

@ -19,10 +19,11 @@ from models.agent import (
Agent,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.enums import WorkflowRunTriggeredFrom
from models.model import App, AppMode, UploadFile
from models.model import UploadFile
from models.snippet import CustomizedSnippet, SnippetType
from models.tools import WorkflowToolProvider
from models.workflow import (
@ -39,13 +40,13 @@ from models.workflow import (
from repositories.factory import DifyAPIRepositoryFactory
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
from services.tag_service import TagService
from services.workflow_node_execution_trace_service import (
WorkflowNodeExecutionTrace,
assemble_workflow_node_execution_traces,
)
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -360,51 +361,51 @@ class SnippetService:
snippet: CustomizedSnippet,
account_id: str | None = None,
) -> bool:
"""
Delete a snippet.
"""Stage Snippet deletion in the caller's transaction.
Workflow rows and all of their binding owners are deleted in that
transaction. A single ``after_commit`` callback performs Agent
retirement, so rollback does not trigger cleanup.
:param session: Database session
:param snippet: Snippet to delete
:return: True if deleted successfully
"""
SnippetService._delete_draft_variable_files(session=session, snippet=snippet)
owned_agents = session.scalars(
select(Agent).where(
Agent.tenant_id == snippet.tenant_id,
Agent.app_id == snippet.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
)
).all()
now = datetime.now(UTC).replace(tzinfo=None)
backing_app_ids = {agent.backing_app_id for agent in owned_agents if agent.backing_app_id}
for agent in owned_agents:
agent.status = AgentStatus.ARCHIVED
agent.archived_by = account_id
agent.archived_at = now
agent.updated_by = account_id or agent.updated_by
agent.updated_at = now
if backing_app_ids:
session.execute(
delete(App)
.where(
App.tenant_id == snippet.tenant_id,
App.id.in_(backing_app_ids),
App.mode == AppMode.AGENT,
candidate_agent_ids = {
agent_id
for agent_id in session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(
WorkflowAgentNodeBinding.tenant_id == snippet.tenant_id,
WorkflowAgentNodeBinding.app_id == snippet.id,
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
WorkflowAgentNodeBinding.agent_id.is_not(None),
)
.execution_options(synchronize_session=False)
)
).all()
if agent_id
}
candidate_agent_ids.update(
session.scalars(
select(Agent.id).where(
Agent.tenant_id == snippet.tenant_id,
Agent.app_id == snippet.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.source.in_(WORKFLOW_ONLY_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
)
).all()
)
if candidate_agent_ids:
tenant_id = snippet.tenant_id
def cleanup_backing_apps(_session: Session) -> None:
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
def collect_agent_resources(_session: Session) -> None:
WorkflowAgentRetirementService.retire_unowned(
tenant_id=tenant_id,
agent_ids=candidate_agent_ids,
account_id=account_id,
)
for app_id in backing_app_ids:
remove_app_and_related_data_task.delay(tenant_id=tenant_id, app_id=app_id)
event.listen(session, "after_commit", cleanup_backing_apps, once=True)
event.listen(session, "after_commit", collect_agent_resources, once=True)
session.execute(
delete(WorkflowAgentNodeBinding)
@ -620,16 +621,11 @@ class SnippetService:
)
self._commit_if_owned(session)
if self._session is None:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return workflow
def restore_published_workflow_to_draft(
@ -679,16 +675,11 @@ class SnippetService:
)
self._commit_if_owned(session)
if self._session is None:
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=snippet.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=snippet.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
return draft_workflow
def publish_workflow(
@ -697,7 +688,7 @@ class SnippetService:
session: Session,
snippet: CustomizedSnippet,
account: Account,
) -> tuple[Workflow, set[str]]:
) -> Workflow:
"""
Publish the draft workflow as a new version.
@ -748,7 +739,7 @@ class SnippetService:
kind=WorkflowKind.SNIPPET.value,
)
session.add(workflow)
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
session=session,
draft_workflow=draft_workflow,
published_workflow=workflow,
@ -761,7 +752,7 @@ class SnippetService:
snippet.updated_by = account.id
session.add(snippet)
return workflow, retirement_candidates
return workflow
def get_all_published_workflows(
self,
@ -842,6 +833,53 @@ class SnippetService:
session.add(workflow)
return workflow
def delete_workflow(
self,
*,
session: Session,
snippet: CustomizedSnippet,
workflow_id: str,
) -> bool:
"""
Delete a published snippet workflow version.
:param session: Database session
:param snippet: CustomizedSnippet instance
:param workflow_id: Workflow ID
:return: True if successful
:raises: ValueError if workflow not found
:raises: WorkflowInUseError if workflow is the snippet's active version or published as a tool
:raises: DraftWorkflowDeletionError if workflow is a draft version
"""
stmt = select(Workflow).where(
Workflow.id == workflow_id,
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
)
workflow = session.scalar(stmt)
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
if workflow.version == Workflow.VERSION_DRAFT:
raise DraftWorkflowDeletionError("Cannot delete draft workflow versions")
if snippet.workflow_id == workflow.id:
raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by snippet '{snippet.id}'")
tool_provider = session.scalar(
select(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == snippet.tenant_id,
WorkflowToolProvider.app_id == snippet.id,
WorkflowToolProvider.version == workflow.version,
)
)
if tool_provider:
raise WorkflowInUseError("Cannot delete workflow that is published as a tool")
session.delete(workflow)
return True
# --- Default Block Configs ---
def get_default_block_configs(self) -> list[dict]:

View File

@ -0,0 +1,103 @@
"""Application boundary for Console tag management."""
from collections.abc import Sequence
from typing import Literal, NamedTuple, Protocol
from machinery.context import RequestContext
type TagKind = Literal["knowledge", "app", "snippet"]
class TagSummary(NamedTuple):
id: str
name: str
type: str
binding_count: int
class CreateTagInput(NamedTuple):
name: str
type: TagKind
class UpdateTagInput(NamedTuple):
name: str
class TagBindingInput(NamedTuple):
tag_ids: tuple[str, ...]
target_id: str
type: TagKind
class TagStore(Protocol):
def list_tags(self, workspace_id: str, tag_type: str, keyword: str | None) -> Sequence[TagSummary]: ...
def get_tag_type(self, workspace_id: str, tag_id: str) -> str | None: ...
def create_tag(self, workspace_id: str, actor_id: str, tag: CreateTagInput) -> TagSummary: ...
def update_tag(self, workspace_id: str, tag_id: str, tag: UpdateTagInput) -> TagSummary: ...
def delete_tag(self, workspace_id: str, tag_id: str) -> None: ...
def create_bindings(self, workspace_id: str, actor_id: str, binding: TagBindingInput) -> None: ...
def delete_bindings(self, workspace_id: str, binding: TagBindingInput) -> None: ...
class TagApplicationError(Exception):
"""Base class for framework-neutral tag failures."""
class TagNotFoundError(TagApplicationError):
def __init__(self) -> None:
super().__init__("Tag not found")
class TagNameConflictError(TagApplicationError):
def __init__(self) -> None:
super().__init__("Tag name already exists")
class TagBindingTargetNotFoundError(TagApplicationError):
def __init__(self, target_type: TagKind) -> None:
target_name = {"knowledge": "Dataset", "app": "App", "snippet": "Snippet"}[target_type]
super().__init__(f"{target_name} not found")
class InvalidTagBindingTypeError(TagApplicationError):
def __init__(self) -> None:
super().__init__("Invalid binding type")
class TagApplicationService:
def __init__(self, *, tags: TagStore) -> None:
self._tags = tags
def list_tags(self, context: RequestContext, tag_type: str, keyword: str | None = None) -> tuple[TagSummary, ...]:
return tuple(self._tags.list_tags(self._workspace_id(context), tag_type, keyword))
def get_tag_type(self, context: RequestContext, tag_id: str) -> str | None:
return self._tags.get_tag_type(self._workspace_id(context), tag_id)
def create_tag(self, context: RequestContext, tag: CreateTagInput) -> TagSummary:
return self._tags.create_tag(self._workspace_id(context), context.account_id, tag)
def update_tag(self, context: RequestContext, tag_id: str, tag: UpdateTagInput) -> TagSummary:
return self._tags.update_tag(self._workspace_id(context), tag_id, tag)
def delete_tag(self, context: RequestContext, tag_id: str) -> None:
self._tags.delete_tag(self._workspace_id(context), tag_id)
def create_bindings(self, context: RequestContext, binding: TagBindingInput) -> None:
self._tags.create_bindings(self._workspace_id(context), context.account_id, binding)
def delete_bindings(self, context: RequestContext, binding: TagBindingInput) -> None:
self._tags.delete_bindings(self._workspace_id(context), binding)
@staticmethod
def _workspace_id(context: RequestContext) -> str:
if context.active_workspace_id is None:
raise RuntimeError("Console account admission did not resolve an active workspace")
return context.active_workspace_id

View File

@ -0,0 +1,7 @@
"""Port for recording recommended trial app usage."""
from typing import Protocol
class TrialAppUsageRecorder(Protocol):
def record(self, *, app_id: str, account_id: str) -> None: ...

View File

@ -0,0 +1,102 @@
"""Application service for building the public Web app runtime bootstrap."""
import json
from collections.abc import Callable, Mapping
from typing import NamedTuple, Protocol, cast
from services.app_definition_query_service import AppSiteConfiguration
from services.entities.feature_entities import FeatureModel
from services.file_service import FileService
class WebAppRuntimeRecord(NamedTuple):
app_id: str
tenant_id: str
mode: str
enable_site: bool
site: AppSiteConfiguration
plan: str
tenant_status: str
# Keep this lazy: workspaces without custom branding never parsed this legacy field.
tenant_custom_config_json: str | None
class WebAppBootstrap(NamedTuple):
app_id: str
mode: str
enable_site: bool
site: Mapping[str, str | bool | None]
plan: str
can_replace_logo: bool
custom_config: Mapping[str, str | bool | None] | None
class WebAppRuntimeQuery(Protocol):
def get_runtime_record(self, app_id: str) -> WebAppRuntimeRecord | None: ...
class WebAppRuntimeUnavailableError(ValueError):
"""Raised when the admitted Web app can no longer be bootstrapped."""
_ARCHIVED_TENANT_STATUS = "archive"
class WebAppRuntimeQueryService:
def __init__(
self,
*,
runtime: WebAppRuntimeQuery,
file_service: FileService,
workspace_features: Callable[[str], FeatureModel],
files_url: str,
) -> None:
self._runtime = runtime
self._file_service = file_service
self._workspace_features = workspace_features
self._files_url = files_url
def get_bootstrap(self, app_id: str) -> WebAppBootstrap:
record = self._runtime.get_runtime_record(app_id)
if record is None or record.tenant_status == _ARCHIVED_TENANT_STATUS:
raise WebAppRuntimeUnavailableError("Site not found")
features = self._workspace_features(record.tenant_id)
site_icon_url = (
self._file_service.get_icon_url(record.site.icon, record.tenant_id)
if record.site.icon_type == "image" and record.site.icon
else None
)
site = cast(dict[str, str | bool | None], record.site._asdict())
site["icon_url"] = site_icon_url
if features.billing.enabled and not features.webapp_copyright_enabled:
site["copyright"] = None
site["input_placeholder"] = None
custom_config = None
if features.can_replace_logo:
tenant_custom_config = (
cast(Mapping[str, str | bool | None], json.loads(record.tenant_custom_config_json))
if record.tenant_custom_config_json
else {}
)
replace_webapp_logo = (
f"{self._files_url}/files/workspaces/{record.tenant_id}/webapp-logo"
if tenant_custom_config.get("replace_webapp_logo")
else None
)
custom_config = {
"remove_webapp_brand": tenant_custom_config.get("remove_webapp_brand", False),
"replace_webapp_logo": replace_webapp_logo,
}
return WebAppBootstrap(
app_id=record.app_id,
mode=record.mode,
enable_site=record.enable_site,
site=site,
plan=record.plan,
can_replace_logo=features.can_replace_logo,
custom_config=custom_config,
)

View File

@ -5,6 +5,8 @@ from typing import Protocol
from enums import WebAppAccessMode
_PERMISSION_CHECK_MODES = frozenset({WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL})
class WebAppAccessQuery(Protocol):
def find_app_id_by_code(self, app_code: str) -> str | None: ...
@ -29,10 +31,12 @@ class WebAppAccessQueryService:
access: WebAppAccessQuery,
webapp_auth_enabled: bool,
access_mode_for_app: Callable[[str], WebAppAccessMode],
is_user_allowed_for_app: Callable[[str, str], bool],
) -> None:
self._access = access
self._webapp_auth_enabled = webapp_auth_enabled
self._access_mode_for_app = access_mode_for_app
self._is_user_allowed_for_app = is_user_allowed_for_app
def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
if not self._webapp_auth_enabled:
@ -47,3 +51,12 @@ class WebAppAccessQueryService:
raise WebAppAccessReferenceRequiredError("appId or appCode must be provided")
return self._access_mode_for_app(app_id)
def requires_permission_check(self, app_id: str) -> bool:
return self._access_mode_for_app(app_id) in _PERMISSION_CHECK_MODES
def is_user_allowed(self, *, user_id: str, app_id: str) -> bool:
if not self._webapp_auth_enabled:
return True
return self._is_user_allowed_for_app(user_id, app_id)

View File

@ -12,9 +12,12 @@ from socketio.exceptions import TimeoutError as SocketIOTimeoutError # type: ig
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from models.account import Account
from models.model import App
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository, WorkflowSessionInfo
from services.enterprise.rbac_service import RBACService
logger = logging.getLogger(__name__)
@ -112,7 +115,7 @@ class WorkflowCollaborationService:
if not user_id or not tenant_id:
return None
if not self._can_access_workflow(workflow_id, str(tenant_id), session=session):
if not self._can_access_workflow(workflow_id, str(tenant_id), str(user_id), session=session):
logger.warning(
"Workflow collaboration join rejected: workflow_id=%s tenant_id=%s user_id=%s sid=%s",
workflow_id,
@ -148,10 +151,27 @@ class WorkflowCollaborationService:
return str(user_id), is_leader
def _can_access_workflow(self, workflow_id: str, tenant_id: str, *, session: Session) -> bool:
"""Check room access without relying on Flask's app-context-bound scoped session."""
app_id = session.scalar(select(App.id).where(App.id == workflow_id, App.tenant_id == tenant_id).limit(1))
return app_id is not None
def _can_access_workflow(self, workflow_id: str, tenant_id: str, user_id: str, *, session: Session) -> bool:
"""Check tenant and app permission without relying on Flask's scoped session."""
with session.begin():
app = session.execute(
select(App.id, App.maintainer).where(
App.id == workflow_id, App.tenant_id == tenant_id, App.status == "normal"
)
).one_or_none()
if app is None:
return False
app_id, maintainer = app
if not dify_config.RBAC_ENABLED or maintainer == user_id:
return True
return RBACService.CheckAccess.check(
tenant_id,
user_id,
scene=RBACPermission.APP_EDIT,
resource_type=RBACResourceScope.APP,
resource_id=app_id,
)
def disconnect_session(self, sid: str) -> None:
mapping = self._repository.get_sid_mapping(sid)

View File

@ -80,6 +80,7 @@ from graphon.variables.input_entities import VariableEntityType
from graphon.variables.variables import Variable
from libs.datetime_utils import naive_utc_now
from models import Account
from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.human_input import HumanInputFormRecipient, RecipientType
from models.model import App, AppMode
from models.tools import WorkflowToolProvider
@ -93,7 +94,6 @@ from services.errors.app import (
WorkflowHashNotEqualError,
WorkflowNotFoundError,
)
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@dataclass(frozen=True)
@ -280,14 +280,21 @@ class WorkflowService:
.with_for_update()
)
def get_published_workflow_by_id(self, app_model: App, workflow_id: str, *, session: Session) -> Workflow | None:
"""
fetch published workflow by workflow_id
def get_published_workflow_by_id(
self,
app_model: App,
workflow_id: str,
*,
session: Session,
for_update: bool = False,
) -> Workflow | None:
"""Fetch a published workflow by ID in the caller's transaction.
Reuses the caller's active session so workflow reads stay in the same
transaction as the surrounding request or task.
With ``for_update=True``, the source version stays locked until that
transaction ends. Restore uses the lock while copying Agent bindings so
a concurrent delete cannot release the same owner.
"""
workflow = session.scalar(
stmt = (
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,
@ -296,6 +303,9 @@ class WorkflowService:
)
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
workflow = session.scalar(stmt)
if not workflow:
return None
if workflow.version == Workflow.VERSION_DRAFT:
@ -328,15 +338,17 @@ class WorkflowService:
return workflow
def get_accessible_app_ids(self, app_ids: Sequence[str], tenant_id: str, *, session: Session) -> set[str]:
"""
Return app IDs that belong to the given tenant.
"""
def get_tenant_app_maintainers(
self, app_ids: Sequence[str], tenant_id: str, *, session: Session
) -> dict[str, str | None]:
"""Return requested normal apps and their maintainers within a tenant."""
if not app_ids:
return set()
return {}
stmt = select(App.id).where(App.id.in_(app_ids), App.tenant_id == tenant_id)
return {str(app_id) for app_id in session.scalars(stmt).all()}
stmt = select(App.id, App.maintainer).where(
App.id.in_(app_ids), App.tenant_id == tenant_id, App.status == "normal"
)
return {str(app_id): maintainer for app_id, maintainer in session.execute(stmt)}
def get_all_published_workflow(
self,
@ -491,16 +503,11 @@ class WorkflowService:
# commit db session changes
if commit:
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
# trigger app workflow events
if commit:
@ -624,7 +631,10 @@ class WorkflowService:
published workflow so the normal draft sync flow stays stateless.
"""
source_workflow = self.get_published_workflow_by_id(
app_model=app_model, workflow_id=workflow_id, session=session
app_model=app_model,
workflow_id=workflow_id,
session=session,
for_update=True,
)
if not source_workflow:
raise WorkflowNotFoundError("Workflow not found.")
@ -656,16 +666,11 @@ class WorkflowService:
)
session.commit()
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
WorkflowAgentRetirementService.retire_unowned(
tenant_id=app_model.tenant_id,
agent_ids=retirement_candidates,
account_id=account.id,
)
enqueue_agent_resource_collection(
tenant_id=app_model.tenant_id,
binding_ids=binding_ids,
home_snapshot_ids=home_snapshot_ids,
)
app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow)
return draft_workflow
@ -678,7 +683,7 @@ class WorkflowService:
account: Account,
marked_name: str = "",
marked_comment: str = "",
) -> tuple[Workflow, set[str]]:
) -> Workflow:
draft_workflow_stmt = select(Workflow).where(
Workflow.tenant_id == app_model.tenant_id,
Workflow.app_id == app_model.id,
@ -752,7 +757,7 @@ class WorkflowService:
# commit db session changes
session.add(workflow)
retirement_candidates = WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
session=session,
draft_workflow=draft_workflow,
published_workflow=workflow,
@ -766,7 +771,7 @@ class WorkflowService:
)
# return new workflow
return workflow, retirement_candidates
return workflow
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
"""
@ -1901,21 +1906,29 @@ class WorkflowService:
return workflow
def delete_workflow(self, *, session: Session, workflow_ref: WorkflowRef) -> bool:
"""
Delete a workflow
def delete_workflow(self, *, session: Session, workflow_ref: WorkflowRef) -> list[str]:
"""Stage a published Workflow and its binding owners for deletion.
The exact owner key is tenant, App, Workflow, and Workflow version. The
Workflow row lock serializes source-version reads and restoration with
deletion. The caller must commit successfully before retiring the
returned, sorted and deduplicated inline Agent candidates.
:param session: SQLAlchemy database session
:param workflow_ref: Owner-bound workflow reference
:return: True if successful
:return: Inline Agent IDs whose owner binding is staged for deletion
:raises: ValueError if workflow not found
:raises: WorkflowInUseError if workflow is in use
:raises: DraftWorkflowDeletionError if workflow is a draft version
"""
stmt = select(Workflow).where(
Workflow.id == workflow_ref.workflow_id,
Workflow.tenant_id == workflow_ref.tenant_id,
Workflow.app_id == workflow_ref.owner_id,
stmt = (
select(Workflow)
.where(
Workflow.id == workflow_ref.workflow_id,
Workflow.tenant_id == workflow_ref.tenant_id,
Workflow.app_id == workflow_ref.owner_id,
)
.with_for_update()
)
workflow = session.scalar(stmt)
@ -1947,8 +1960,25 @@ class WorkflowService:
# Cannot delete a workflow that's published as a tool
raise WorkflowInUseError("Cannot delete workflow that is published as a tool")
bindings = session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
)
).all()
retirement_candidates = sorted(
{
binding.agent_id
for binding in bindings
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and binding.agent_id
}
)
for binding in bindings:
session.delete(binding)
session.delete(workflow)
return True
return retirement_candidates
def _setup_variable_pool(

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