Files
dify/api/tests/unit_tests/services/app/test_console_service.py
T

1641 lines
67 KiB
Python

"""Console use cases exercise ports without a request or an ORM session."""
import base64
import json
import os
from collections.abc import Callable, Sequence
from contextlib import AbstractContextManager
from dataclasses import dataclass, field, replace
from datetime import datetime
from io import BytesIO
from typing import BinaryIO, Literal, cast, override
from uuid import UUID, uuid4
from zipfile import ZipFile
import pytest
import yaml
from blinker import Signal
from sqlalchemy import Engine, event, func, select
from sqlalchemy.orm import Session, sessionmaker
from core.plugin.entities.plugin import PluginDependency
from core.plugin.impl.plugin import PluginInstaller
from core.rbac import RBACPermission
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolProviderType
from core.tools.tool_manager import ToolManager
from events.app_event import app_was_updated
from extensions.application_services.app import AppServices
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from graphon.model_runtime.entities.model_entities import ModelType
from libs.datetime_utils import naive_utc_now
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, IconType, InstalledApp, Site
from models.provider import TenantDefaultModel
from models.provider_ids import GenericProviderID
from models.tools import ApiToolProvider, ToolLabelBinding, WorkflowToolProvider
from models.workflow import Workflow, WorkflowType
from repositories.app.console_repository import ConsoleAppRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
from services.agent.errors import (
AgentAccessNotReadyError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
)
from services.agent.roster_package_entities import RosterAgentPackageExport
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_importer import RosterAgentPackageImporter
from services.agent.roster_service import AgentRosterService
from services.app.console_gateway import AppLifecycleGateway, AppTransferGateway
from services.app.console_service import (
AppExportPaidPlanRequiredError,
ConsoleAppNotFoundError,
ConsoleApps,
ConsoleAppService,
CreatorsPlatformDisabledError,
InvalidAppExportError,
)
from services.app_dsl_service import AppDslService
from services.app_package_service import AppPackageService
from services.app_tracing_config_service import AppTraceSettings
from services.entities.app_entities import (
AppCreationSettings,
AppDeletion,
AppExportOptions,
AppListParams,
AppPage,
AppRecord,
AppReference,
CopyAppParams,
CreateAppParams,
ImportedAppPackage,
RecentAppListItem,
StarredAppListParams,
UpdateAppParams,
)
from services.entities.dsl_entities import (
AppImportPackage,
AppImportParams,
CheckDependenciesResult,
Import,
ImportStatus,
PackagedWorkflowTool,
)
from services.entities.feature_entities import FeatureModel, LimitationModel
from services.errors.base import NoPermissionError
from services.feature_service import FeatureService
from services.model_provider.service import ModelProviderService
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.workflow_tool_references import WorkflowToolTarget
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
from tests.helpers.unit.model_factories import make_account, make_tenant
from tests.helpers.unit.rbac_fakes import RBACDomain
CONTEXT = RequestContext("request", "trace", "actor", "workspace")
RECORD = AppRecord(id="app", name="Example", mode_compatible_with_agent="chat")
@dataclass
class Apps:
source: AppReference = AppReference("app", "Example", "chat", None)
records: list[AppRecord] = field(default_factory=lambda: [RECORD])
calls: list[tuple[object, ...]] = field(default_factory=list)
def list_apps(self, context: RequestContext, params: AppListParams | StarredAppListParams) -> AppPage:
self.calls.append(("list", context, params))
return AppPage(params.page, params.limit, len(self.records), False, self.records)
def recent(self, context: RequestContext, params: AppListParams) -> list[RecentAppListItem]:
self.calls.append(("recent", context, params))
return [RecentAppListItem("app", "Example", None, None, None, AppMode.CHAT, None, datetime(2026, 1, 1), None)]
def get(self, context: RequestContext, app_id: str) -> AppRecord:
self.calls.append(("get", context, app_id))
return RECORD
def create(self, context: RequestContext, params: CreateAppParams, settings: AppCreationSettings) -> AppRecord:
assert settings.app == {"mode": params.mode}
self.calls.append(("create", context, params))
return RECORD
def get_reference(self, context: RequestContext, app_id: str) -> AppReference:
self.calls.append(("get_reference", context, app_id))
return self.source
def get_trace(self, context: RequestContext, app_id: str) -> AppTraceSettings:
self.calls.append(("get_trace", context, app_id))
raise ValueError("Stored tracing JSON is malformed")
def set_trace(self, context: RequestContext, app_id: str, settings: AppTraceSettings) -> None:
self.calls.append(("set_trace", context, app_id, settings))
@dataclass
class Permissions:
def filter_list(self, params: AppListParams) -> AppListParams:
return params.model_copy(update={"accessible_app_ids": ["app"]})
def keys_for(self, app_ids: list[str]) -> dict[str, list[str]]:
return {app_id: ["app.preview"] for app_id in app_ids}
@dataclass
class Access:
paid: bool = True
calls: list[tuple[object, ...]] = field(default_factory=list)
def require_import(self, context: RequestContext, kind: Literal["url", "dsl", "agent"]) -> None:
del context, kind
pytest.fail("Unexpected import admission")
def imported_permissions(self, context: RequestContext, app_id: str) -> list[str]:
del context, app_id
pytest.fail("Unexpected import permissions lookup")
def initialize_import_access(self, app_id: str) -> None:
del app_id
pytest.fail("Unexpected import access initialization")
def permissions(self, context: RequestContext, *, app_id: str | None = None) -> Permissions:
self.calls.append(("permissions", context, app_id))
return Permissions()
def created_permissions(self, context: RequestContext, app_id: str) -> list[str]:
self.calls.append(("created_permissions", context, app_id))
return ["app.edit"]
def initialize_created_app(self, context: RequestContext, app_id: str) -> None:
self.calls.append(("initialize", context, app_id))
def inherit_access(self, source_app_id: str, app_id: str) -> None:
self.calls.append(("inherit", source_app_id, app_id))
def access_modes(self, app_ids: list[str]) -> dict[str, str]:
self.calls.append(("modes", app_ids))
return dict.fromkeys(app_ids, "private")
def access_mode(self, app_id: str) -> str | None:
del app_id
return "private"
def can_export_version(self, workspace_id: str) -> bool:
self.calls.append(("paid", workspace_id))
return self.paid
@dataclass
class Transfers:
status: ImportStatus = ImportStatus.COMPLETED
copied_id: str | None = RECORD.id
calls: list[tuple[object, ...]] = field(default_factory=list)
def download_import(self, url: str) -> AbstractContextManager[BinaryIO]:
del url
pytest.fail("Unexpected import download")
def read_import_yaml(self, source: BinaryIO) -> str | None:
del source
pytest.fail("Unexpected import content detection")
def read_app_package(self, source: BinaryIO) -> AppImportPackage | None:
del source
pytest.fail("Unexpected package read")
def confirm_import(self, context: RequestContext, import_id: str) -> tuple[Import, bool]:
del context, import_id
pytest.fail("Unexpected import confirmation")
def import_agent_package(self, context: RequestContext, source: BinaryIO) -> ImportedAppPackage:
del context, source
pytest.fail("Unexpected Agent package import")
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
del context, params
pytest.fail("Unexpected Agent template import")
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
del context, params
pytest.fail("Unexpected Agent package URL import")
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
del context, app_id
pytest.fail("Unexpected dependency check")
def plan_workflow_tools(
self, context: RequestContext, tools: Sequence[PackagedWorkflowTool], *, creates_root_app: bool
) -> dict[str, WorkflowToolTarget]:
del context, tools, creates_root_app
pytest.fail("Unexpected workflow tool planning")
def create_workflow_tool(
self, context: RequestContext, *, app_id: str, tool: PackagedWorkflowTool, target: WorkflowToolTarget
) -> None:
del context, app_id, tool, target
pytest.fail("Unexpected workflow tool creation")
def import_dsl(
self,
context: RequestContext,
params: AppImportParams,
*,
package: AppImportPackage | None = None,
preserve_agent_bindings: bool = False,
) -> Import:
assert package is None
self.calls.append(("import", context, params, preserve_agent_bindings))
return Import(id="import", status=self.status, app_id=self.copied_id)
def import_copy(self, context: RequestContext, params: AppImportParams) -> Import:
self.calls.append(("copy", context, params))
return Import(id="import", status=self.status, app_id=self.copied_id)
def export_dsl(self, context: RequestContext, app_id: str, options: AppExportOptions) -> str:
self.calls.append(("dsl", context, app_id, options))
return "app: example"
def export_app_package(
self, context: RequestContext, app_id: str, options: AppExportOptions
) -> RosterAgentPackageExport:
self.calls.append(("package", context, app_id, options))
return RosterAgentPackageExport(archive=BytesIO(b"package"), filename="example.ifpkg", size=7)
def export_agent_package(
self, *, workspace_id: str, agent_id: str, version_id: UUID | None
) -> RosterAgentPackageExport:
self.calls.append(("agent", workspace_id, agent_id, version_id))
return RosterAgentPackageExport(archive=BytesIO(b"agent"), filename="agent.ifpkg", size=5)
@dataclass
class Creators:
enabled: bool = True
failure: Literal["upload", "authorize"] | None = None
uploads: list[BinaryIO] = field(default_factory=list)
calls: list[tuple[object, ...]] = field(default_factory=list)
def require_enabled(self) -> None:
if not self.enabled:
raise CreatorsPlatformDisabledError
def upload(self, source: BinaryIO, *, filename: str) -> str:
self.uploads.append(source)
self.calls.append(("upload", filename, source.read()))
if self.failure == "upload":
raise RuntimeError("Upload failed")
return "claim"
def authorize(self, account_id: str) -> str | None:
assert all(source.closed for source in self.uploads)
self.calls.append(("authorize", account_id))
if self.failure == "authorize":
raise RuntimeError("Authorization failed")
return "code"
def redirect_url(self, claim_code: str, oauth_code: str | None) -> str:
self.calls.append(("redirect", claim_code, oauth_code))
return "https://creators.example.com"
class Tracing:
def set_trace(self, context: RequestContext, app_id: str, settings: AppTraceSettings) -> None:
del context, app_id
if settings.tracing_provider not in (None, "langfuse"):
raise ValueError("Invalid tracing provider")
Ports = tuple[ConsoleAppService, Apps, Access, Transfers, Creators]
@pytest.fixture
def ports() -> Ports:
apps, access, transfers, creators = Apps(), Access(), Transfers(), Creators()
service = ConsoleAppService(
apps=cast(ConsoleApps, apps),
access=access,
transfers=transfers,
creators=creators,
tracing=Tracing(),
lifecycle=Lifecycle(),
)
return service, apps, access, transfers, creators
def test_list_applies_visibility_and_enriches_materialized_records(ports: Ports) -> None:
service, apps, access, _, _ = ports
params = AppListParams(page=2, limit=5)
result = service.list_apps(CONTEXT, params)
_, context, filtered = apps.calls[0]
assert context == CONTEXT
assert isinstance(filtered, AppListParams)
assert filtered.accessible_app_ids == ["app"]
assert params.accessible_app_ids is None
assert result.page == 2
assert result.data[0].permission_keys == ["app.preview"]
assert result.data[0].access_mode == "private"
assert RECORD.access_mode is None
assert access.calls == [("permissions", CONTEXT, None), ("modes", ["app"])]
def test_empty_list_skips_external_enrichment(ports: Ports) -> None:
service, apps, access, _, _ = ports
apps.records = []
assert service.list_apps(CONTEXT, AppListParams()).data == []
assert access.calls == [("permissions", CONTEXT, None)]
def test_starred_list_preserves_existing_visibility_contract(ports: Ports) -> None:
service, apps, access, _, _ = ports
params = StarredAppListParams()
page = service.list_apps(CONTEXT, params)
assert page.data[0].permission_keys == ["app.preview"]
assert access.calls == [("permissions", CONTEXT, None), ("modes", ["app"])]
# visibility filtering is still skipped for starred lists
_, _, listed_params = apps.calls[0]
assert listed_params is params
def test_recent_uses_list_visibility_and_permission_keys(ports: Ports) -> None:
service, apps, _, _, _ = ports
assert service.recent(CONTEXT, 3)[0].permission_keys == ["app.preview"]
params = apps.calls[0][2]
assert isinstance(params, AppListParams)
assert params.limit == 3
assert params.accessible_app_ids == ["app"]
def test_detail_and_creation_have_distinct_permission_lookups(ports: Ports) -> None:
service, _, access, _, _ = ports
assert service.get(CONTEXT, "app").permission_keys == ["app.preview"]
assert access.calls == [("permissions", CONTEXT, "app")]
access.calls.clear()
assert service.create(CONTEXT, CreateAppParams(name="Example", mode="chat")).permission_keys == ["app.edit"]
assert access.calls == [("created_permissions", CONTEXT, "app"), ("initialize", CONTEXT, "app")]
@pytest.mark.parametrize("status", [ImportStatus.FAILED, ImportStatus.PENDING])
def test_copy_pending_or_failure_skips_created_permissions(ports: Ports, status: ImportStatus) -> None:
service, apps, access, transfers, _ = ports
transfers.status = status
result, copied = service.copy(CONTEXT, "app", CopyAppParams())
assert result.status == status
assert copied is None
assert apps.calls == []
assert access.calls == []
def test_copy_success_requires_materialized_app_and_enriches_permissions(ports: Ports) -> None:
service, apps, access, transfers, _ = ports
params = CopyAppParams(name="Copy", description="Description", icon_type=IconType.EMOJI, icon="robot")
_, copied = service.copy(CONTEXT, "app", params)
assert copied is not None
assert copied.permission_keys == ["app.edit"]
assert access.calls == [("inherit", "app", "app"), ("created_permissions", CONTEXT, "app")]
assert apps.calls == [("get", CONTEXT, "app")]
assert transfers.calls == [
("dsl", CONTEXT, "app", AppExportOptions(include_secret=True, preserve_agent_bindings=True)),
(
"copy",
CONTEXT,
AppImportParams(mode="yaml-content", yaml_content="app: example", **params.model_dump()),
),
]
transfers.copied_id = None
with pytest.raises(ConsoleAppNotFoundError):
service.copy(CONTEXT, "app", CopyAppParams())
def test_export_routes_yaml_ordinary_package_and_agent_package(ports: Ports) -> None:
service, apps, _, transfers, _ = ports
options = AppExportOptions(format="yaml", include_secret=True, workflow_id="workflow")
assert service.export(CONTEXT, "app", options) == "app: example"
assert transfers.calls[-1] == ("dsl", CONTEXT, "app", options)
archive = service.export(CONTEXT, "app", AppExportOptions())
assert transfers.calls[-1] == ("package", CONTEXT, "app", AppExportOptions())
assert isinstance(archive, RosterAgentPackageExport)
archive.close()
apps.source = replace(apps.source, mode="agent", bound_agent_id="agent")
version = uuid4()
archive = service.export(CONTEXT, "app", AppExportOptions(version_id=version))
assert transfers.calls[-1] == ("agent", "workspace", "agent", version)
assert isinstance(archive, RosterAgentPackageExport)
archive.close()
def test_export_rejects_invalid_or_unpaid_version_before_exporting(ports: Ports) -> None:
service, apps, access, transfers, _ = ports
options = AppExportOptions(version_id=uuid4())
with pytest.raises(InvalidAppExportError):
service.export(CONTEXT, "app", options)
apps.source = replace(apps.source, mode="agent", bound_agent_id="agent")
access.paid = False
with pytest.raises(AppExportPaidPlanRequiredError):
service.export(CONTEXT, "app", options)
assert transfers.calls == []
access.paid = True
apps.source = replace(apps.source, bound_agent_id=None)
with pytest.raises(ConsoleAppNotFoundError, match="Agent not found"):
service.export(CONTEXT, "app", AppExportOptions())
@pytest.mark.parametrize("mode", ["chat", "workflow", "agent"])
def test_publish_orders_package_upload_authorization_and_redirect_without_secrets(ports: Ports, mode: str) -> None:
service, apps, _, transfers, creators = ports
apps.source = replace(apps.source, mode=mode, bound_agent_id="agent" if mode == "agent" else None)
assert service.publish(CONTEXT, "app") == "https://creators.example.com"
if mode == "agent":
assert transfers.calls == [("agent", "workspace", "agent", None)]
filename, payload = "agent.ifpkg", b"agent"
else:
assert transfers.calls == [("package", CONTEXT, "app", AppExportOptions(format="ifpkg", include_secret=False))]
filename, payload = "example.ifpkg", b"package"
assert creators.calls == [("upload", filename, payload), ("authorize", "actor"), ("redirect", "claim", "code")]
assert len(creators.uploads) == 1
assert creators.uploads[0].closed
assert apps.calls == [("get_reference", CONTEXT, "app")]
@pytest.mark.parametrize("failure", ["upload", "authorize"])
def test_publish_closes_package_and_stops_after_failure(ports: Ports, failure: Literal["upload", "authorize"]) -> None:
service, _, _, _, creators = ports
creators.failure = failure
with pytest.raises(RuntimeError, match="failed"):
service.publish(CONTEXT, "app")
assert creators.uploads[0].closed
expected: list[tuple[object, ...]] = [("upload", "example.ifpkg", b"package")]
if failure == "authorize":
expected.append(("authorize", "actor"))
assert creators.calls == expected
def test_publish_rejects_unbound_agent_before_upload(ports: Ports) -> None:
service, apps, _, transfers, creators = ports
apps.source = replace(apps.source, mode="agent")
with pytest.raises(ConsoleAppNotFoundError, match="Agent not found"):
service.publish(CONTEXT, "app")
assert transfers.calls == creators.calls == []
def test_disabled_creators_has_no_export_or_external_effects(ports: Ports) -> None:
service, _, _, transfers, creators = ports
creators.enabled = False
with pytest.raises(CreatorsPlatformDisabledError):
service.publish(CONTEXT, "app")
assert transfers.calls == creators.calls == []
@pytest.mark.parametrize(
("enabled", "provider"),
[
pytest.param(False, None, id="disabled-without-provider"),
pytest.param(False, "langfuse", id="disabled-with-provider"),
pytest.param(True, "langfuse", id="enabled"),
pytest.param(True, "invalid", id="invalid"),
],
)
def test_trace_update_checks_ownership_and_provider_without_parsing_old_settings(
ports: Ports, enabled: bool, provider: str | None
) -> None:
service, apps, _, _, _ = ports
settings = AppTraceSettings(enabled, provider)
if provider == "invalid":
with pytest.raises(ValueError, match="Invalid tracing provider"):
service.set_trace(CONTEXT, "app", settings)
assert apps.calls == [("get_reference", CONTEXT, "app")]
else:
service.set_trace(CONTEXT, "app", settings)
assert apps.calls == [("get_reference", CONTEXT, "app")]
class Lifecycle:
def delete(self, context: RequestContext, app_id: str) -> AppDeletion:
del context, app_id
pytest.fail("Unexpected app deletion")
def create(self, context: RequestContext, params: CreateAppParams, settings: AppCreationSettings) -> AppRecord:
assert context is CONTEXT
assert settings.app == {"mode": params.mode}
return RECORD
def create_draft(self, context: RequestContext, app_id: str) -> None:
del context, app_id
pytest.fail("Unexpected draft creation")
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
assert context is CONTEXT
return AppCreationSettings({"mode": params.mode}, None)
def created(self, context: RequestContext, app: AppRecord) -> None:
assert context is CONTEXT
assert app.id == "app"
def updated(self, context: RequestContext, app: AppRecord) -> None:
assert context is CONTEXT
assert app.id == "app"
def deleted(self, context: RequestContext, deleted: AppDeletion) -> None:
assert context is CONTEXT
assert deleted.app.id == "app"
def present(self, context: RequestContext, app: AppRecord, *, mask_credentials: bool = False) -> AppRecord:
del mask_credentials
assert context is CONTEXT
return app
@pytest.mark.parametrize(
("provider_name", "expected_model"),
[
pytest.param(
"langgenius/openai/openai",
("langgenius/openai", "langgenius/openai/openai", "gpt-4o"),
id="workspace-default",
),
pytest.param(None, None, id="no-default"),
pytest.param("invalid/provider", None, id="invalid-provider"),
],
)
def test_lifecycle_agent_creation_seeds_workspace_default_model(
sqlite_engine: Engine,
monkeypatch: pytest.MonkeyPatch,
provider_name: str | None,
expected_model: tuple[str, str, str] | None,
) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
with factory.begin() as session:
account = Account(name="Creator", email=f"{uuid4()}@example.com")
tenant = Tenant(name="Workspace")
session.add_all(
[
account,
tenant,
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
),
]
)
if provider_name is not None:
session.add(
TenantDefaultModel(
tenant_id=tenant.id,
model_type=ModelType.LLM,
provider_name=provider_name,
model_name="gpt-4o",
)
)
if provider_name is None:
monkeypatch.setattr(
ModelProviderService,
"get_default_model_selection",
lambda *_args, **_kwargs: None,
)
context = RequestContext("request", None, account.id, tenant.id)
app = AppLifecycleGateway(session_factory=factory).create(
context,
CreateAppParams(name="Agent", mode=AppMode.AGENT.value),
AppCreationSettings({"enable_site": False, "enable_api": False}, None),
)
with factory() as session:
agent = session.scalar(select(Agent).where(Agent.app_id == app.id))
assert agent is not None
snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id)
assert snapshot is not None
model = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict).model
if expected_model is None:
assert model is None
assert agent.active_config_has_model is False
else:
assert model is not None
assert (model.plugin_id, model.model_provider, model.model) == expected_model
assert agent.active_config_has_model is True
@pytest.mark.parametrize("failure", [None, "persist", "external"])
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.AGENT])
def test_lifecycle_runs_after_atomic_creation_and_session_close(
sqlite_engine: Engine, failure: str | None, mode: AppMode
) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
with factory.begin() as session:
account = Account(name="Creator", email=f"{uuid4()}@example.com")
tenant = Tenant(name="Workspace")
session.add_all(
[
account,
tenant,
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
),
]
)
context = RequestContext("request", None, account.id, tenant.id)
effects: list[str] = []
checked_out: set[object] = set()
def checkout(connection: object, *_args: object) -> None:
checked_out.add(connection)
def checkin(connection: object, *_args: object) -> None:
checked_out.remove(connection)
def reject_commit(_session: Session) -> None:
raise RuntimeError("persist failed")
class LifecycleProbe(AppLifecycleGateway):
@override
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
assert context.account_id == account.id
assert not checked_out
assert params.mode == mode
return AppCreationSettings({"enable_site": True, "enable_api": True}, None)
@override
def present(self, context: RequestContext, app: AppRecord, *, mask_credentials: bool = False) -> AppRecord:
assert context.account_id == account.id
assert not mask_credentials
return app
@override
def created(self, context: RequestContext, app: AppRecord) -> None:
assert context.account_id == account.id
assert not checked_out
with factory() as read:
assert read.get(App, app.id) is not None
assert read.scalar(select(Site.id).where(Site.app_id == app.id)) is not None
assert read.scalar(select(InstalledApp.id).where(InstalledApp.app_id == app.id)) is not None
backing_agent = read.scalar(select(Agent).where(Agent.app_id == app.id))
assert (backing_agent is not None) == (mode == AppMode.AGENT)
effects.append("created")
if failure == "external":
raise RuntimeError("external failed")
@override
def updated(self, context: RequestContext, app: AppRecord) -> None:
assert context.account_id == account.id
assert not checked_out
with factory() as read:
persisted = read.get(App, app.id)
assert persisted is not None
assert persisted.name == "Renamed"
effects.append("updated")
@override
def deleted(self, context: RequestContext, deleted: AppDeletion) -> None:
assert context.account_id == account.id
assert not checked_out
with factory() as read:
assert read.get(App, deleted.app.id) is None
if mode == AppMode.AGENT:
assert (
read.scalar(select(Agent.status).where(Agent.app_id == deleted.app.id)) == AgentStatus.ARCHIVED
)
effects.append("deleted")
access = Access()
service = ConsoleAppService(
apps=ConsoleAppRepository(session_factory=factory),
access=access,
transfers=Transfers(),
creators=Creators(),
tracing=Tracing(),
lifecycle=LifecycleProbe(session_factory=factory),
)
event.listen(sqlite_engine, "checkout", checkout)
event.listen(sqlite_engine, "checkin", checkin)
if failure == "persist":
event.listen(factory, "before_commit", reject_commit)
try:
params = CreateAppParams.model_validate({"name": "Created", "mode": mode})
if failure:
with pytest.raises(RuntimeError, match=f"{failure} failed"):
service.create(context, params)
with factory() as read:
expected = 0 if failure == "persist" else 1
for model in (App, Site, InstalledApp):
assert read.scalar(select(func.count()).select_from(model)) == expected
assert read.scalar(select(func.count()).select_from(Agent)) == (
expected if mode == AppMode.AGENT else 0
)
assert effects == ([] if failure == "persist" else ["created"])
assert access.calls == []
else:
app = service.create(context, params)
service.rename(context, app.id, "Renamed")
service.delete(context, app.id)
assert effects == ["created", "updated", "deleted"]
assert not checked_out
finally:
if failure == "persist":
event.remove(factory, "before_commit", reject_commit)
event.remove(sqlite_engine, "checkout", checkout)
event.remove(sqlite_engine, "checkin", checkin)
def test_app_deletion_failure_rolls_back_backing_agent_changes(sqlite_engine: Engine) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
with factory.begin() as session:
account = Account(name="Creator", email=f"{uuid4()}@example.com")
tenant = Tenant(name="Workspace")
session.add_all(
[
account,
tenant,
TenantAccountJoin(
tenant_id=tenant.id, account_id=account.id, current=True, role=TenantAccountRole.OWNER
),
]
)
context = RequestContext("request", None, account.id, tenant.id)
gateway = AppLifecycleGateway(session_factory=factory)
app = gateway.create(
context,
CreateAppParams(name="Agent", mode="agent"),
AppCreationSettings({"enable_site": False, "enable_api": False}, None),
)
def reject_commit(_session: Session) -> None:
raise RuntimeError("commit failed")
event.listen(factory, "before_commit", reject_commit)
try:
with pytest.raises(RuntimeError, match="commit failed"):
gateway.delete(context, app.id)
finally:
event.remove(factory, "before_commit", reject_commit)
with factory() as read:
assert read.get(App, app.id) is not None
agent = read.scalar(select(Agent).where(Agent.app_id == app.id))
assert agent is not None
assert agent.status == AgentStatus.ACTIVE
assert agent.archived_at is None
@pytest.mark.parametrize("operation", ["get", "update", "create", "copy"])
@pytest.mark.parametrize("remote_fails", [False, True])
def test_detail_tool_enrichment_releases_database_before_plugin_io(
sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, operation: str, remote_fails: bool
) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
missing_api = str(uuid4())
with factory.begin() as session:
account = Account(name="Creator", email=f"{uuid4()}@example.com")
tenant = Tenant(name="Workspace")
session.add_all(
[
account,
tenant,
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
),
]
)
providers = [
ApiToolProvider(
name="API",
icon="",
schema="{}",
schema_type_str=ApiProviderSchemaType.OPENAPI,
user_id=account.id,
tenant_id=workspace,
description="",
tools_str="[]",
credentials_str="{}",
)
for workspace in (tenant.id, str(uuid4()))
]
session.add_all(providers)
tools = [
{
"provider_type": kind,
"provider_id": provider_id,
"tool_name": "tool",
"tool_parameters": dict[str, str](),
}
for kind, provider_id in (
("api", providers[0].id),
("api", missing_api),
("api", providers[1].id),
("builtin", "vendor/missing/tool"),
("builtin", "vendor/installed/tool"),
("builtin", "local"),
)
]
config_values = {"agent_mode": json.dumps({"enabled": False, "tools": tools}), "model": "{}"}
app = App(
id=str(uuid4()), tenant_id=tenant.id, name="Example", mode=AppMode.CHAT, enable_site=True, enable_api=True
)
config = AppModelConfig(app_id=app.id, agent_mode=config_values["agent_mode"], model=config_values["model"])
session.add_all([app, config])
session.flush()
app.app_model_config_id = config.id
context = RequestContext("request", None, account.id, tenant.id)
repository = ConsoleAppRepository(session_factory=factory)
checked_out: set[object] = set()
remote_calls: list[list[str]] = []
def checkout(connection: object, *_args: object) -> None:
checked_out.add(connection)
def checkin(connection: object, *_args: object) -> None:
checked_out.remove(connection)
def hardcoded(provider: str) -> object:
assert not checked_out
if provider == "local":
return object()
raise ValueError("Not a hardcoded provider")
def check_plugins(_self: PluginInstaller, tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> list[bool]:
assert tenant_id == tenant.id
assert not checked_out, "Plugin daemon I/O must run after returning the database connection"
remote_calls.append([str(provider) for provider in provider_ids])
if remote_fails:
raise RuntimeError("Plugin daemon unavailable")
return [False, True]
class LifecycleGateway(AppLifecycleGateway):
@override
def prepare_creation(self, context: RequestContext, params: CreateAppParams) -> AppCreationSettings:
assert context.account_id == account.id
assert params.mode == "chat"
return AppCreationSettings({"enable_site": True, "enable_api": True}, config_values)
@override
def created(self, context: RequestContext, app: AppRecord) -> None:
assert context.account_id == account.id
assert app.created_by == account.id
assert not checked_out
class CopyTransfer(Transfers):
@override
def import_copy(self, context: RequestContext, params: AppImportParams) -> Import:
assert params.name is None
return Import(id="import", status=ImportStatus.COMPLETED, app_id=app.id)
service = ConsoleAppService(
apps=repository,
access=Access(),
transfers=CopyTransfer(),
creators=Creators(),
tracing=Tracing(),
lifecycle=LifecycleGateway(session_factory=factory),
)
monkeypatch.setattr(ToolManager, "get_hardcoded_provider", hardcoded)
monkeypatch.setattr(PluginInstaller, "check_tools_existence", check_plugins)
event.listen(sqlite_engine, "checkout", checkout)
event.listen(sqlite_engine, "checkin", checkin)
def read_result() -> AppRecord:
if operation == "get":
return service.get(context, app.id)
if operation == "update":
return service.update(context, app.id, UpdateAppParams(name="Updated"))
if operation == "create":
return service.create(context, CreateAppParams(name="Created", mode="chat"))
_, copied = service.copy(context, app.id, CopyAppParams())
assert copied is not None
return copied
try:
snapshot = repository.get(context, app.id)
assert remote_calls == []
assert not checked_out
assert [ref.exists for ref in snapshot.tool_references] == [True, False, False, None, None, None]
if remote_fails:
with pytest.raises(RuntimeError, match="Plugin daemon unavailable"):
read_result()
with factory() as read:
if operation == "update":
assert read.scalar(select(App.name).where(App.id == app.id)) == "Updated"
if operation == "create":
assert read.scalar(select(App.id).where(App.name == "Created")) is not None
else:
result = read_result()
assert result.deleted_tools == [
{"type": "api", "provider_id": missing_api, "tool_name": "tool"},
{"type": "api", "provider_id": providers[1].id, "tool_name": "tool"},
{"type": "builtin", "provider_id": "vendor/missing/tool", "tool_name": "tool"},
]
assert remote_calls == [["vendor/missing/tool", "vendor/installed/tool"]]
assert not checked_out
finally:
event.remove(sqlite_engine, "checkout", checkout)
event.remove(sqlite_engine, "checkin", checkin)
@pytest.mark.parametrize("operation", ["rename", "icon", "site"])
@pytest.mark.parametrize("commit_fails", [False, True])
def test_app_mutations_publish_only_after_commit(sqlite_engine: Engine, operation: str, commit_fails: bool) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False)
context = RequestContext("request", None, str(uuid4()), str(uuid4()))
with factory.begin() as session:
app = App(
tenant_id=context.active_workspace_id,
name="Before",
mode=AppMode.COMPLETION,
icon_type=IconType.IMAGE,
icon="old",
icon_background="#fff",
enable_site=False,
enable_api=False,
)
session.add(app)
service = ConsoleAppService(
apps=ConsoleAppRepository(session_factory=factory),
access=Access(),
transfers=Transfers(),
creators=Creators(),
tracing=Tracing(),
lifecycle=AppLifecycleGateway(session_factory=factory),
)
signals: list[str] = []
checked_out: set[object] = set()
def checkout(connection: object, *_args: object) -> None:
checked_out.add(connection)
def checkin(connection: object, *_args: object) -> None:
checked_out.remove(connection)
def after_update(_sender: object) -> None:
assert not checked_out
with factory() as read:
persisted = read.get(App, app.id)
assert persisted is not None
assert persisted.updated_by == context.account_id
assert (persisted.name, persisted.icon, persisted.enable_site) == (
"After" if operation == "rename" else "Before",
"new" if operation == "icon" else "old",
operation == "site",
)
assert persisted.icon_type == "image"
signals.append("updated")
def reject_commit(_session: Session) -> None:
raise RuntimeError("commit failed")
def update() -> AppRecord:
if operation == "rename":
return service.rename(context, app.id, "After")
if operation == "icon":
return service.update_icon(context, app.id, icon="new", icon_background="#000", icon_type=None)
return service.set_site_enabled(context, app.id, True)
app_was_updated.connect(after_update)
event.listen(sqlite_engine, "checkout", checkout)
event.listen(sqlite_engine, "checkin", checkin)
if commit_fails:
event.listen(factory, "before_commit", reject_commit)
try:
if commit_fails:
with pytest.raises(RuntimeError, match="commit failed"):
update()
with factory() as read:
persisted = read.get(App, app.id)
assert persisted is not None
assert (persisted.name, persisted.icon, persisted.enable_site) == ("Before", "old", False)
assert signals == []
else:
result = update()
assert result.updated_by == context.account_id
if operation == "site":
assert service.set_site_enabled(context, app.id, True).enable_site
assert signals == ["updated"]
assert not checked_out
finally:
if commit_fails:
event.remove(factory, "before_commit", reject_commit)
event.remove(sqlite_engine, "checkout", checkout)
event.remove(sqlite_engine, "checkin", checkin)
app_was_updated.disconnect(after_update)
@pytest.mark.parametrize("surface", ["site", "api"])
def test_unpublished_agent_cannot_enable_access_through_console_service(sqlite_engine: Engine, surface: str) -> None:
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
context = RequestContext("request", None, str(uuid4()), str(uuid4()))
with factory.begin() as session:
app = App(
id=str(uuid4()),
tenant_id=context.active_workspace_id,
name="Agent",
mode=AppMode.AGENT,
enable_site=False,
enable_api=False,
)
session.add(app)
session.flush()
AgentRosterService(session).create_backing_agent_for_app(
tenant_id=context.active_workspace_id,
account_id=context.account_id,
app_id=app.id,
name="Agent",
)
service = ConsoleAppService(
apps=ConsoleAppRepository(session_factory=factory),
access=Access(),
transfers=Transfers(),
creators=Creators(),
tracing=Tracing(),
lifecycle=AppLifecycleGateway(session_factory=factory),
)
signals: list[object] = []
def updated(sender: object) -> None:
signals.append(sender)
app_was_updated.connect(updated)
set_enabled = service.set_site_enabled if surface == "site" else service.set_api_enabled
try:
with pytest.raises(AgentAccessNotReadyError):
set_enabled(context, app.id, True)
assert signals == []
with factory() as read:
persisted = read.get(App, app.id)
assert persisted is not None
assert not persisted.enable_site
assert not persisted.enable_api
finally:
app_was_updated.disconnect(updated)
_TOOL_NAME = "summarize"
@dataclass(frozen=True)
class Workspaces:
source: RequestContext
target: RequestContext
caller_app_id: str
tool_app_id: str
provider_id: str
def _no_plugin_dependencies(*, tenant_id: str, dependencies: list[str]) -> list[PluginDependency]:
del tenant_id
assert dependencies == []
return []
def _features_with_one_free_app(tenant_id: str, *, exclude_vector_space: bool) -> FeatureModel:
del tenant_id, exclude_vector_space
return FeatureModel(apps=LimitationModel(size=4, limit=5))
def _workflow(*, tenant_id: str, app_id: str, version: str, graph: dict[str, object], created_by: str) -> Workflow:
return Workflow(
id=str(uuid4()),
tenant_id=tenant_id,
app_id=app_id,
type=WorkflowType.WORKFLOW,
version=version,
graph=json.dumps(graph),
_features=json.dumps({}),
_environment_variables=[],
_conversation_variables=[],
created_by=created_by,
)
def _workflow_tool(
*, provider_id: str, tenant_id: str, app_id: str, version: str, user_id: str
) -> WorkflowToolProvider:
provider = WorkflowToolProvider(
name=_TOOL_NAME,
label="Summarize",
icon=json.dumps({"background": "#FFEAD5", "content": "🤖"}),
app_id=app_id,
version=version,
user_id=user_id,
tenant_id=tenant_id,
description="Summarize a document",
parameter_configuration="[]",
)
provider.id = provider_id
return provider
def _tool_node(provider_id: str, tool_name: str) -> dict[str, object]:
return {
"id": tool_name,
"data": {
"type": "tool",
"title": tool_name,
"provider_id": provider_id,
"provider_type": "workflow",
"provider_name": tool_name,
"tool_name": tool_name,
"tool_label": tool_name,
"tool_configurations": dict[str, object](),
"tool_parameters": dict[str, object](),
},
}
@pytest.fixture
def workspaces(
sqlite_session_factory: sessionmaker[Session],
sqlite_engine: Engine,
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> Workspaces:
config_overrides(RBAC_ENABLED=False, DEPLOYMENT_EDITION="COMMUNITY")
# Workflow Tool management opens its own sessions from the Flask-SQLAlchemy engine.
monkeypatch.setattr(type(db), "engine", property(lambda _: sqlite_engine))
# Plugin metadata is resolved by the plugin daemon; these Apps use no plugins.
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", _no_plugin_dependencies)
# Workflow event handlers reach the Flask-bound database; these tests check the imported records.
monkeypatch.setattr("services.workflow_service.app_draft_workflow_was_synced", Signal())
monkeypatch.setattr("services.workflow_service.app_published_workflow_was_updated", Signal())
actor, source_id, target_id = str(uuid4()), str(uuid4()), str(uuid4())
caller_app_id, tool_app_id, provider_id = str(uuid4()), str(uuid4()), str(uuid4())
version = Workflow.version_from_datetime(naive_utc_now())
published = _workflow(
tenant_id=source_id,
app_id=tool_app_id,
version=version,
graph={"nodes": list[object](), "edges": list[object]()},
created_by=actor,
)
caller_graph: dict[str, object] = {"nodes": [_tool_node(provider_id, _TOOL_NAME)], "edges": list[object]()}
with sqlite_session_factory.begin() as session:
session.add_all(
[
make_account(account_id=actor, name="Creator", email=f"{actor}@example.com"),
make_tenant(tenant_id=source_id, name="Source"),
make_tenant(tenant_id=target_id, name="Target"),
TenantAccountJoin(tenant_id=source_id, account_id=actor, role=TenantAccountRole.OWNER),
TenantAccountJoin(tenant_id=target_id, account_id=actor, role=TenantAccountRole.OWNER),
App(
id=tool_app_id,
tenant_id=source_id,
name="Summarizer",
mode=AppMode.WORKFLOW,
enable_site=True,
enable_api=True,
workflow_id=published.id,
),
published,
_workflow_tool(
provider_id=provider_id, tenant_id=source_id, app_id=tool_app_id, version=version, user_id=actor
),
ToolLabelBinding(tool_id=provider_id, tool_type=ToolProviderType.WORKFLOW, label_name="productivity"),
App(
id=caller_app_id,
tenant_id=source_id,
name="Caller",
mode=AppMode.WORKFLOW,
enable_site=True,
enable_api=True,
),
_workflow(
tenant_id=source_id,
app_id=caller_app_id,
version=Workflow.VERSION_DRAFT,
graph=caller_graph,
created_by=actor,
),
]
)
return Workspaces(
source=RequestContext("request", None, actor, source_id),
target=RequestContext("request", None, actor, target_id),
caller_app_id=caller_app_id,
tool_app_id=tool_app_id,
provider_id=provider_id,
)
def _export_and_import(
app_services: AppServices, workspaces: Workspaces, context: RequestContext, params: AppImportParams
) -> Import:
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
assert not isinstance(exported, str)
with exported:
return app_services.console.import_app(context, params, source=exported.archive)
def _caller_tool_node(session: Session, app_id: str) -> dict[str, object]:
draft = session.scalar(
select(Workflow).where(Workflow.app_id == app_id, Workflow.version == Workflow.VERSION_DRAFT)
)
assert draft is not None
[node] = draft.graph_dict["nodes"]
return node["data"]
def _call_ranker_from_summarizer(
sqlite_session_factory: sessionmaker[Session], workspaces: Workspaces, ranker_nodes: list[dict[str, object]]
) -> str:
"""Make the summarizer call a second Workflow Tool, ``rank``, whose published workflow runs ``ranker_nodes``."""
source_id, actor = workspaces.source.active_workspace_id, workspaces.source.account_id
ranker_app_id, ranker_provider_id = str(uuid4()), str(uuid4())
version = Workflow.version_from_datetime(naive_utc_now())
ranker_workflow = _workflow(
tenant_id=source_id,
app_id=ranker_app_id,
version=version,
graph={"nodes": ranker_nodes, "edges": list[object]()},
created_by=actor,
)
ranker = _workflow_tool(
provider_id=ranker_provider_id, tenant_id=source_id, app_id=ranker_app_id, version=version, user_id=actor
)
ranker.name = "rank"
with sqlite_session_factory.begin() as session:
session.add_all(
[
App(
id=ranker_app_id,
tenant_id=source_id,
name="Ranker",
mode=AppMode.WORKFLOW,
enable_site=True,
enable_api=True,
workflow_id=ranker_workflow.id,
),
ranker_workflow,
ranker,
]
)
summarizer = session.scalar(
select(Workflow).where(
Workflow.app_id == workspaces.tool_app_id, Workflow.version != Workflow.VERSION_DRAFT
)
)
assert summarizer is not None
summarizer.graph = json.dumps({"nodes": [_tool_node(ranker_provider_id, "rank")], "edges": list[object]()})
return ranker_provider_id
def test_export_bundles_the_pinned_workflow_and_its_tool_definition(
app_services: AppServices, workspaces: Workspaces
) -> None:
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
assert not isinstance(exported, str)
with exported, ZipFile(exported.archive) as archive:
manifest = yaml.safe_load(archive.read("manifest.yaml"))
assert manifest["format_version"] == 2
[tool] = manifest["workflow_tools"]
assert (tool["provider_id"], tool["name"], tool["labels"]) == (
workspaces.provider_id,
_TOOL_NAME,
["productivity"],
)
with ZipFile(archive.open(tool["path"])) as nested:
assert yaml.safe_load(nested.read("manifest.yaml"))["format_version"] == 2
assert yaml.safe_load(nested.read("app.yaml"))["app"]["name"] == "Summarizer"
@pytest.mark.parametrize(
("maintained_by_caller", "allowed", "bundled"),
[(False, False, False), (False, True, True), (True, False, True)],
ids=["denied", "allowed", "maintainer"],
)
def test_export_bundles_only_workflows_the_caller_may_export(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
rbac_domain: RBACDomain,
config_overrides: Callable[..., None],
maintained_by_caller: bool,
allowed: bool,
bundled: bool,
) -> None:
config_overrides(RBAC_ENABLED=True)
rbac_domain.transport.response = {"allowed": allowed}
if maintained_by_caller:
with sqlite_session_factory.begin() as session:
tool_app = session.get(App, workspaces.tool_app_id)
assert tool_app is not None
tool_app.maintainer = workspaces.source.account_id
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
assert not isinstance(exported, str)
with exported, ZipFile(exported.archive) as archive:
manifest = yaml.safe_load(archive.read("manifest.yaml"))
dsl = yaml.safe_load(archive.read("app.yaml"))
assert manifest["format_version"] == 2
assert [tool["provider_id"] for tool in manifest.get("workflow_tools", [])] == (
[workspaces.provider_id] if bundled else []
)
# A workflow that is not bundled stays a plain reference, as exports were before packages carried workflows.
assert dsl["workflow"]["graph"]["nodes"][0]["data"]["provider_id"] == workspaces.provider_id
checked_apps = [request.json["resource_id"] for request in rbac_domain.transport.requests if request.json]
assert checked_apps == ([] if maintained_by_caller else [workspaces.tool_app_id])
def test_import_into_another_workspace_recreates_the_called_workflow_as_a_tool(
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
) -> None:
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
assert result.status == ImportStatus.COMPLETED
target_id = workspaces.target.active_workspace_id
with sqlite_session_factory() as session:
[provider] = session.scalars(select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == target_id))
assert provider.id != workspaces.provider_id
assert provider.name == _TOOL_NAME
tool_app = session.get(App, provider.app_id)
assert tool_app is not None
assert (tool_app.tenant_id, tool_app.name) == (target_id, "Summarizer")
published = session.get(Workflow, tool_app.workflow_id)
assert published is not None
assert published.version == provider.version
assert list(session.scalars(select(ToolLabelBinding.label_name).where(ToolLabelBinding.tool_id == provider.id)))
assert _caller_tool_node(session, str(result.app_id))["provider_id"] == provider.id
assert {app.id for app in session.scalars(select(App).where(App.tenant_id == target_id))} == {
tool_app.id,
result.app_id,
}
def test_import_into_the_same_workspace_reuses_the_existing_tool(
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
) -> None:
result = _export_and_import(app_services, workspaces, workspaces.source, AppImportParams(mode="yaml-content"))
assert result.status == ImportStatus.COMPLETED
source_id = workspaces.source.active_workspace_id
with sqlite_session_factory() as session:
providers = session.scalars(select(WorkflowToolProvider.id).where(WorkflowToolProvider.tenant_id == source_id))
assert list(providers) == [workspaces.provider_id]
assert session.scalar(select(App.id).where(App.id == result.app_id)) is not None
assert len(list(session.scalars(select(App.id).where(App.tenant_id == source_id)))) == 3
assert _caller_tool_node(session, str(result.app_id))["provider_id"] == workspaces.provider_id
def test_import_renames_a_tool_whose_name_is_taken(
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
) -> None:
target_id = workspaces.target.active_workspace_id
taken_app_id = str(uuid4())
with sqlite_session_factory.begin() as session:
session.add_all(
[
App(
id=taken_app_id,
tenant_id=target_id,
name="Taken",
mode=AppMode.WORKFLOW,
enable_site=True,
enable_api=True,
),
_workflow_tool(
provider_id=str(uuid4()),
tenant_id=target_id,
app_id=taken_app_id,
version="1",
user_id=workspaces.target.account_id,
),
]
)
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
assert result.status == ImportStatus.COMPLETED
with sqlite_session_factory() as session:
provider = session.scalar(
select(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == target_id, WorkflowToolProvider.app_id != taken_app_id
)
)
assert provider is not None
assert provider.name == f"{_TOOL_NAME}_2"
node = _caller_tool_node(session, str(result.app_id))
assert (node["provider_id"], node["tool_name"]) == (provider.id, f"{_TOOL_NAME}_2")
def test_import_requires_permission_to_create_tools_before_creating_anything(
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
) -> None:
target_id = workspaces.target.active_workspace_id
with sqlite_session_factory.begin() as session:
join = session.scalar(select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == target_id))
assert join is not None
join.role = TenantAccountRole.EDITOR
with pytest.raises(NoPermissionError, match="workflow tools"):
_export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
with sqlite_session_factory() as session:
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
@pytest.mark.parametrize("broker_available", [True, False])
def test_failed_import_deletes_the_workflows_it_created(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
broker_available: bool,
) -> None:
cleanups: list[dict[str, str]] = []
def enqueue_cleanup(**kwargs: str) -> None:
cleanups.append(kwargs)
if not broker_available:
raise ConnectionError("The message broker is unavailable")
monkeypatch.setattr(remove_app_and_related_data_task, "delay", enqueue_cleanup)
monkeypatch.setattr("services.app_service.app_was_deleted", Signal())
target_id = workspaces.target.active_workspace_id
result = _export_and_import(
app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content", app_id=str(uuid4()))
)
assert result.status == ImportStatus.FAILED
[cleanup] = cleanups
assert cleanup["tenant_id"] == target_id
assert ("left by a failed package import" in caplog.text) is not broker_available
with sqlite_session_factory() as session:
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
def test_import_recreates_workflows_that_call_each_other(
app_services: AppServices, workspaces: Workspaces, sqlite_session_factory: sessionmaker[Session]
) -> None:
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [_tool_node(workspaces.provider_id, _TOOL_NAME)])
result = _export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
assert result.status == ImportStatus.COMPLETED
target_id = workspaces.target.active_workspace_id
with sqlite_session_factory() as session:
providers = {
provider.name: provider
for provider in session.scalars(
select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == target_id)
)
}
assert set(providers) == {_TOOL_NAME, "rank"}
for name, called in ((_TOOL_NAME, "rank"), ("rank", _TOOL_NAME)):
published = session.scalar(
select(Workflow).where(
Workflow.app_id == providers[name].app_id, Workflow.version == providers[name].version
)
)
assert published is not None
[node] = published.graph_dict["nodes"]
assert node["data"]["provider_id"] == providers[called].id
def test_import_checks_the_quota_for_every_app_it_creates(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> None:
exported = app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
assert not isinstance(exported, str)
config_overrides(DEPLOYMENT_EDITION="CLOUD")
# Billing limits come from the billing service; one App slot is left for a package that needs two.
monkeypatch.setattr(FeatureService, "get_features", _features_with_one_free_app)
with exported, pytest.raises(NoPermissionError, match="limit"):
app_services.console.import_app(
workspaces.target, AppImportParams(mode="yaml-content"), source=exported.archive
)
target_id = workspaces.target.active_workspace_id
with sqlite_session_factory() as session:
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
@pytest.mark.parametrize(
("knowledge_node", "error", "created_apps"),
[
({}, "'rank' could not be imported", 1),
({"query_variable_selector": ["start", "query"]}, "'rank' could not be created", 2),
],
ids=["invalid-node", "missing-knowledge-base"],
)
def test_import_deletes_created_workflows_when_a_called_workflow_fails(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
knowledge_node: dict[str, object],
error: str,
created_apps: int,
) -> None:
knowledge: dict[str, object] = {
"id": "knowledge",
"data": {
"type": "knowledge-retrieval-v2",
"title": "Knowledge",
"control_space_ids": [str(uuid4())],
**knowledge_node,
},
}
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [knowledge])
cleanups: list[dict[str, str]] = []
monkeypatch.setattr(remove_app_and_related_data_task, "delay", lambda **kwargs: cleanups.append(kwargs))
monkeypatch.setattr("services.app_service.app_was_deleted", Signal())
with pytest.raises(InvalidRosterAgentPackageError, match=error):
_export_and_import(app_services, workspaces, workspaces.target, AppImportParams(mode="yaml-content"))
target_id = workspaces.target.active_workspace_id
assert len(cleanups) == created_apps
with sqlite_session_factory() as session:
assert list(session.scalars(select(App.id).where(App.tenant_id == target_id))) == []
def test_export_stops_at_the_workflow_tool_limit(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
config_overrides: Callable[..., None],
) -> None:
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [])
config_overrides(APP_PACKAGE_MAX_WORKFLOW_TOOLS=1)
with pytest.raises(RosterAgentPackageTooLargeError, match="too many workflow tools"):
app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
def test_export_stops_once_the_workflow_tools_outgrow_the_package_limit(
app_services: AppServices,
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
config_overrides: Callable[..., None],
) -> None:
_call_ranker_from_summarizer(sqlite_session_factory, workspaces, [])
with sqlite_session_factory.begin() as session:
for app in session.scalars(select(App).where(App.id != workspaces.caller_app_id)):
# Random data keeps each packaged workflow near its uncompressed size.
app.description = base64.b64encode(os.urandom(180_000)).decode()
# Each workflow fits on its own; the two together do not.
config_overrides(AGENT_PACKAGE_MAX_BYTES=300_000)
with pytest.raises(RosterAgentPackageTooLargeError, match="App package workflow tools exceed"):
app_services.console.export(workspaces.source, workspaces.caller_app_id, AppExportOptions())
@pytest.mark.parametrize("allowed", [True, False])
def test_planning_new_workflow_tools_checks_tool_management_under_rbac(
workspaces: Workspaces,
sqlite_session_factory: sessionmaker[Session],
rbac_domain: RBACDomain,
config_overrides: Callable[..., None],
allowed: bool,
) -> None:
config_overrides(RBAC_ENABLED=True)
rbac_domain.transport.response = {"allowed": allowed}
gateway = AppTransferGateway(
session_factory=sqlite_session_factory,
dsl_factory=AppDslService,
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=RecommendedAppPackageService(
sources=DatabaseRecommendedAppCatalogRepository(session_factory=sqlite_session_factory, redis=redis_client),
exporter=RosterAgentPackageExporter(),
),
)
tool = PackagedWorkflowTool(
id="w_000001",
path="w_000001.ifpkg",
size=0,
sha256="0" * 64,
provider_id=workspaces.provider_id,
name=_TOOL_NAME,
label="Summarize",
icon={"background": "#FFEAD5", "content": "🤖"},
description="",
parameters=[],
labels=[],
privacy_policy="",
)
if allowed:
targets = gateway.plan_workflow_tools(workspaces.target, [tool], creates_root_app=True)
assert targets[workspaces.provider_id].create
else:
with pytest.raises(NoPermissionError, match="workflow tools"):
gateway.plan_workflow_tools(workspaces.target, [tool], creates_root_app=True)
request_json = rbac_domain.transport.only_request.json
assert request_json is not None
assert request_json["scene"] == RBACPermission.TOOL_MANAGE