Compare commits

..

1 Commits

Author SHA1 Message Date
dadachann 41bc5e69f5 style: format workspace identity modules 2026-07-31 15:45:28 +00:00
91 changed files with 501 additions and 3903 deletions
-59
View File
@@ -1,59 +0,0 @@
name: Build and deploy production
on:
push:
branches: [deploy/prod]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: langbot-production
cancel-in-progress: false
env:
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build exact Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
${{ env.CORE_IMAGE }}:deploy-prod
cache-from: type=gha,scope=core-prod
cache-to: type=gha,mode=max,scope=core-prod
- name: Checkout production Cloud adapter
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-space
ref: ${{ env.SPACE_REF }}
token: ${{ secrets.CLA_PAT }}
path: .space
- name: Build exact Cloud Core image
uses: docker/build-push-action@v6
with:
context: .space
file: .space/Dockerfile.cloud
push: true
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
tags: |
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
${{ env.CLOUD_IMAGE }}:deploy-prod
cache-from: type=gha,scope=cloud-core-prod
cache-to: type=gha,mode=max,scope=cloud-core-prod
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /opt/langbot-cloud-prod
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
rendered_compose=$(docker compose config)
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
exit 4
}
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
exit 5
}
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
echo 'Cloud telemetry ingest token must be configured' >&2
exit 6
}
update_env() {
local key=$1 value=$2
python3 - "$key" "$value" <<'PY'
from pathlib import Path
import os
import sys
path = Path('.env')
key, value = sys.argv[1:]
lines = path.read_text().splitlines()
updated = False
for index, line in enumerate(lines):
if line.startswith(f'{key}='):
lines[index] = f'{key}={value}'
updated = True
break
if not updated:
lines.append(f'{key}={value}')
temporary = Path('.env.tmp')
temporary.write_text('\n'.join(lines) + '\n')
os.chmod(temporary, 0o600)
temporary.replace(path)
PY
}
update_env LANGBOT_IMAGE_TAG "$TAG"
set -a
. ./.env
set +a
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
for attempt in 1 2 3 4 5; do
if docker compose pull postgres redis migrate plugin-runtime core; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "docker compose pull failed after $attempt attempts" >&2
exit 1
fi
delay=$((attempt * 10))
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
sleep "$delay"
done
docker compose up -d postgres redis
for _ in $(seq 1 60); do
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
sleep 2
done
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
GRANT USAGE ON SCHEMA public TO langbot_runtime;
SQL
docker compose --profile tools run --rm migrate
docker compose up -d --remove-orphans plugin-runtime core
for _ in $(seq 1 90); do
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
docker compose ps
exit 0
fi
sleep 2
done
docker compose logs --tail=200 core plugin-runtime >&2
exit 1
-162
View File
@@ -1,162 +0,0 @@
services:
postgres:
image: pgvector/pgvector:pg17
container_name: langbot-cloud-postgres
restart: unless-stopped
environment:
POSTGRES_DB: langbot
POSTGRES_USER: langbot_operator
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
interval: 5s
timeout: 5s
retries: 30
networks: [internal]
redis:
image: redis:7.4-alpine
container_name: langbot-cloud-redis
restart: unless-stopped
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 20
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
networks: [internal]
migrate:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
profiles: [tools]
command: [uv, run, langbot, migrate, --cloud]
environment: &core-env
TZ: Asia/Shanghai
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
SYSTEM__EDITION: cloud
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
SYSTEM__JWT__SECRET: ${JWT_SECRET}
SYSTEM__LIMITATION__MAX_BOTS: "2"
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
API__WEBHOOK_PREFIX: https://cloud.langbot.app
API__WEBUI_URL: https://cloud.langbot.app
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
DATABASE__USE: postgresql
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
VDB__USE: pgvector
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
PLUGIN__ENABLE: "true"
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
PLUGIN__WORKER__MAX_CPUS: "0.25"
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
PLUGIN__WORKER__MAX_PIDS: "128"
PLUGIN__WORKER__MAX_WORKERS: "16"
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
# Cloud v2 currently grants no managed Box capability. Keep the shared
# runtime deployed but disable Core integration until a hard-quota-capable
# backend can satisfy the fail-closed Cloud readiness contract.
BOX__ENABLED: "false"
BOX__BACKEND: nsjail
BOX__RUNTIME__ENDPOINT: ws://box:5410
BOX__ADMISSION__REQUIRED: "true"
BOX__ADMISSION__LOGICAL_SESSION_ID: global
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
BOX__ADMISSION__MAX_SESSIONS: "1"
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
BOX__ADMISSION__CPUS: "0.25"
BOX__ADMISSION__MEMORY_MB: "256"
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
BOX__LOCAL__HOST_ROOT: /app/data/box
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
MCP__STDIO__ENABLED: "false"
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
SPACE__URL: https://space.langbot.app
depends_on:
postgres: {condition: service_healthy}
networks: [internal]
plugin-runtime:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-plugin-runtime
restart: unless-stopped
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
environment:
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
volumes:
- plugin-data:/app/data
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5400"]
networks: [internal]
box:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-box
restart: unless-stopped
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
environment:
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
LANGBOT_BOX_ROOT: /app/data/box
volumes:
- box-data:/app/data/box
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5410"]
networks: [internal]
core:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-core
restart: unless-stopped
environment: *core-env
volumes:
- core-data:/app/data
- box-data:/app/data/box
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
plugin-runtime: {condition: service_started}
box: {condition: service_started}
expose: ["5300"]
healthcheck:
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
networks: [internal, shared-network]
networks:
internal:
shared-network:
external: true
volumes:
postgres-data:
redis-data:
plugin-data:
box-data:
core-data:
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.7"
version = "4.10.6"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@101e453e916b39465a6294d6471c9eaae8725d5c",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+2
View File
@@ -19,6 +19,7 @@ class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete'
OWNER_TRANSFER = 'owner.transfer'
MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role'
@@ -48,6 +49,7 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
if permission
not in {
Permission.WORKSPACE_DELETE,
Permission.OWNER_TRANSFER,
Permission.BILLING_LINK_MANAGE,
}
),
@@ -62,6 +62,7 @@ class AuthType(enum.Enum):
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
{
Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value,
@@ -1,7 +1,6 @@
import quart
import argon2
import asyncio
import datetime
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -219,22 +218,7 @@ class UserRouterGroup(group.RouterGroup):
try:
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
workspace_created_ats: dict[str, int] = {}
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
binding = await self.ap.workspace_service.get_execution_binding()
workspace_uuids = [binding.workspace_uuid]
workspace_created_at = binding.workspace_created_at
if workspace_created_at is not None:
if workspace_created_at.tzinfo is None:
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
token_data = await self.ap.space_service.exchange_oauth_code(
code,
workspace_uuids,
workspace_created_ats,
)
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
@@ -247,6 +231,7 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in
)
launch_workspace_uuid = consumed_state.launch_workspace_uuid
if launch_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
@@ -300,25 +285,8 @@ class UserRouterGroup(group.RouterGroup):
request_context.workspace_uuid,
)
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
# Cloud Accounts authenticate through LangBot Account, so every projected
# Workspace owner is already bound even when this Core has no local OAuth
# token row (model billing uses the owner's control-plane API key).
owner_space_bound = cloud_mode or owner_has_local_space_credentials
if cloud_mode:
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
credits = (
catalog_service.get_workspace_credits(access.workspace.uuid)
if catalog_service is not None
else None
)
else:
credits = (
await self.ap.space_service.get_credits(owner.user)
if owner is not None and owner.space_account_uuid
else None
)
owner_space_bound = bool(owner and owner.space_account_uuid)
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
return self.success(
data={
'credits': credits,
@@ -334,10 +302,8 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'initialized': False})
capabilities = await self.ap.user_service.get_login_capabilities()
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -30,14 +30,12 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
def _membership_payload(
membership: WorkspaceMembership,
*,
display_name: str,
email: str,
) -> dict[str, typing.Any]:
return {
'uuid': membership.uuid,
'workspace_uuid': membership.workspace_uuid,
'account_uuid': membership.account_uuid,
'display_name': display_name,
'email': email,
'role': membership.role,
'status': membership.status,
@@ -96,11 +94,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
workspaces.append(
{
'workspace': _workspace_payload(access.workspace),
'membership': _membership_payload(
access.membership,
display_name=account.user,
email=account.normalized_email,
),
'membership': _membership_payload(access.membership, email=account.user),
'permissions': sorted(permissions_for_role(access.membership.role)),
'placement_generation': access.execution.placement_generation,
'plan_name': plan_name,
@@ -143,7 +137,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
'uuid': None,
'workspace_uuid': request_context.workspace_uuid,
'account_uuid': None,
'display_name': None,
'email': None,
'role': 'owner',
'status': 'active',
@@ -161,11 +154,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
return self.success(
data={
'workspace': _workspace_payload(workspace),
'membership': _membership_payload(
membership,
display_name=account.user,
email=account.normalized_email,
),
'membership': _membership_payload(membership, email=account.user),
'permissions': sorted(request_context.workspace.permissions),
'placement_generation': request_context.placement_generation,
'plan_name': plan_name,
@@ -294,8 +283,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
data={
'member': _membership_payload(
member,
display_name=account.user if account is not None else '',
email=account.normalized_email if account is not None else '',
email=account.user if account is not None else '',
)
}
)
@@ -314,11 +302,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
@staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
return _membership_payload(
view.membership,
display_name=view.display_name,
email=view.email,
)
return _membership_payload(view.membership, email=view.email)
@group.group_class('invitations', '/api/v1/invitations')
+5 -70
View File
@@ -5,7 +5,6 @@ import uuid
import sqlalchemy
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
@@ -114,23 +113,6 @@ async def _require_workspace_provider(
return provider
def _is_cloud_runtime(ap: app.Application) -> bool:
mode = getattr(ap.persistence_mgr, 'mode', None)
return getattr(mode, 'value', None) == 'cloud_runtime'
async def _assert_cloud_managed_provider_mutable(
ap: app.Application,
context: TenantContext,
provider_uuid: str,
) -> None:
if not _is_cloud_runtime(ap):
return
provider = await _require_workspace_provider(ap, context, provider_uuid)
if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER:
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
async def _require_runtime_provider(
ap: app.Application,
context: TenantContext,
@@ -231,7 +213,6 @@ class LLMModelsService:
model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
@@ -310,17 +291,11 @@ class LLMModelsService:
return model_dict
async def update_llm_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
@@ -346,7 +321,6 @@ class LLMModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
result = await self.ap.persistence_mgr.execute_async(
@@ -381,11 +355,6 @@ class LLMModelsService:
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an LLM model"""
if _is_cloud_runtime(self.ap):
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
@@ -479,10 +448,7 @@ class EmbeddingModelsService:
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_embedding_model(
self,
context: TenantContext,
model_data: dict,
preserve_uuid: bool = False,
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
) -> str:
"""Create a new embedding model"""
model_data = model_data.copy()
@@ -506,7 +472,6 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
await self.ap.persistence_mgr.execute_async(
@@ -565,17 +530,11 @@ class EmbeddingModelsService:
return model_dict
async def update_embedding_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
@@ -600,7 +559,6 @@ class EmbeddingModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
result = await self.ap.persistence_mgr.execute_async(
@@ -635,11 +593,6 @@ class EmbeddingModelsService:
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an embedding model"""
if _is_cloud_runtime(self.ap):
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
@@ -732,12 +685,7 @@ class RerankModelsService:
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_rerank_model(
self,
context: TenantContext,
model_data: dict,
preserve_uuid: bool = False,
) -> str:
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
model_data = model_data.copy()
if not preserve_uuid:
@@ -760,7 +708,6 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
await self.ap.persistence_mgr.execute_async(
@@ -819,17 +766,11 @@ class RerankModelsService:
return model_dict
async def update_rerank_model(
self,
context: TenantContext,
model_uuid: str,
model_data: dict,
) -> None:
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
model_data = model_data.copy()
model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None)
@@ -854,7 +795,6 @@ class RerankModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, provider_uuid)
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
result = await self.ap.persistence_mgr.execute_async(
@@ -889,11 +829,6 @@ class RerankModelsService:
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete a rerank model"""
if _is_cloud_runtime(self.ap):
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
if existing_model is None:
raise WorkspaceNotFoundError('Model not found')
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.RerankModel).where(
@@ -5,7 +5,6 @@ import traceback
import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
@@ -21,20 +20,6 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
def _is_cloud_runtime(self) -> bool:
mode = getattr(self.ap.persistence_mgr, 'mode', None)
return getattr(mode, 'value', None) == 'cloud_runtime'
def _system_requester_is_reserved(self, requester: object) -> bool:
return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER
async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None:
if not self._is_cloud_runtime():
return
provider = await self.get_provider(context, provider_uuid)
if provider is not None and self._system_requester_is_reserved(provider.get('requester')):
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
@staticmethod
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
if api_keys is None:
@@ -114,8 +99,6 @@ class ModelProviderService:
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
"""Create a new provider"""
provider_data = provider_data.copy()
if self._system_requester_is_reserved(provider_data.get('requester')):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
provider_data['uuid'] = str(uuid.uuid4())
provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys(
@@ -132,10 +115,7 @@ class ModelProviderService:
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider"""
await self._assert_provider_mutable(context, provider_uuid)
provider_data = provider_data.copy()
if self._system_requester_is_reserved(provider_data.get('requester')):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
provider_data.pop('uuid', None)
provider_data.pop('workspace_uuid', None)
if 'api_keys' in provider_data:
@@ -165,7 +145,6 @@ class ModelProviderService:
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
@@ -266,8 +245,6 @@ class ModelProviderService:
api_keys: list,
) -> str:
"""Find existing provider or create new one"""
if self._system_requester_is_reserved(requester):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
workspace_uuid = require_workspace_uuid(context)
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
+2 -18
View File
@@ -59,10 +59,6 @@ class SpaceService:
result_list = result.all()
return result_list[0] if result_list else None
async def get_valid_access_token(self, user_email: str) -> str | None:
"""Return a current Space bearer, refreshing and persisting it when needed."""
return await self._ensure_valid_token(user_email)
async def _ensure_valid_token(self, user_email: str) -> str | None:
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
user_obj = await self._get_user_by_email(user_email)
@@ -121,12 +117,7 @@ class SpaceService:
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
async def exchange_oauth_code(
self,
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
) -> typing.Dict:
async def exchange_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
@@ -136,14 +127,7 @@ class SpaceService:
session = httpclient.get_session()
async with session.post(
f'{space_url}/api/v1/accounts/oauth/token',
json={
'code': code,
'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding.
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
'workspace_created_ats': workspace_created_ats or {},
},
json={'code': code, 'instance_id': constants.instance_id},
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
+2 -21
View File
@@ -779,27 +779,8 @@ class UserService:
local_account = await self.get_user_by_email(user_email)
if local_account is None:
raise ValueError('User not found')
# Exchange code for tokens and bind both installation and the active
# OSS Workspace as independent identities.
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is not None:
binding = await workspace_service.get_execution_binding()
created_at = binding.workspace_created_at
created_ts = (
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
if created_at.tzinfo is None
else int(created_at.timestamp())
)
token_data = await self.ap.space_service.exchange_oauth_code(
code,
[binding.workspace_uuid],
{binding.workspace_uuid: created_ts},
)
else:
# Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code)
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
+2 -13
View File
@@ -13,7 +13,6 @@ from typing import Any, Protocol, runtime_checkable
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
from .model_catalog import CloudModelCatalogProvider
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
@@ -51,7 +50,6 @@ class OpenSourceDeployment:
)
directory_provider: None = None
manifest_provider: None = None
model_catalog_provider: None = None
persistence_mode: str = 'oss_compat'
required_vector_backend: str | None = None
@@ -82,7 +80,6 @@ class VerifiedCloudDeployment:
entitlement_provider: EntitlementProvider
directory_provider: DirectoryProjectionProvider
manifest_provider: CloudManifestProvider
model_catalog_provider: CloudModelCatalogProvider
verification_key_id: str
mode: str = dataclasses.field(default='cloud', init=False)
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
@@ -113,8 +110,6 @@ class VerifiedCloudDeployment:
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
if not isinstance(self.manifest_provider, CloudManifestProvider):
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider):
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter')
def validate_instance_config(self, config: dict[str, Any]) -> None:
try:
@@ -143,14 +138,8 @@ class VerifiedCloudDeployment:
if plugin_worker.get('require_hard_limits') is not True:
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
box_config = config.get('box', {})
box_enabled = box_config.get('enabled')
if box_enabled is False:
# Explicitly disabling Box removes the sandbox surface entirely and
# therefore does not weaken tenant isolation. Validate the strict
# runtime/admission contract only when the surface is enabled.
return
if box_enabled is not True:
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
if box_config.get('enabled') is not True:
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
if box_config.get('backend') != 'nsjail':
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
-327
View File
@@ -1,327 +0,0 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime
from typing import Any, Literal, Protocol, runtime_checkable
import sqlalchemy
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from ..entity.persistence import model as persistence_model
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
_MODEL_TABLES = (
persistence_model.LLMModel,
persistence_model.EmbeddingModel,
persistence_model.RerankModel,
)
class CloudModelCatalogItem(BaseModel):
model_config = ConfigDict(extra='forbid', frozen=True)
uuid: str = Field(min_length=1, max_length=255)
model_id: str = Field(min_length=1, max_length=255)
category: Literal['chat', 'embedding', 'rerank']
llm_abilities: tuple[str, ...] = ()
is_featured: bool = False
featured_order: int = 0
@field_validator('llm_abilities', mode='before')
@classmethod
def normalize_missing_abilities(cls, value: Any) -> Any:
return () if value is None else value
@field_validator('llm_abilities')
@classmethod
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if any(not item.strip() or len(item) > 64 for item in value):
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
if len(set(value)) != len(value):
raise ValueError('Model abilities must be unique')
return value
class CloudWorkspaceModelBilling(BaseModel):
model_config = ConfigDict(extra='forbid', frozen=True)
workspace_uuid: str = Field(min_length=36, max_length=36)
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
api_key: SecretStr | None = None
credits: int | None = None
@field_validator('workspace_uuid')
@classmethod
def validate_uuid(cls, value: str) -> str:
return str(uuid.UUID(value))
@field_validator('owner_account_uuid')
@classmethod
def validate_optional_uuid(cls, value: str | None) -> str | None:
return None if value is None else str(uuid.UUID(value))
class CloudModelCatalogSnapshot(BaseModel):
model_config = ConfigDict(extra='forbid', frozen=True)
instance_uuid: str = Field(min_length=1, max_length=255)
generated_at: datetime
base_url: str = Field(min_length=1, max_length=512)
models: tuple[CloudModelCatalogItem, ...]
workspaces: tuple[CloudWorkspaceModelBilling, ...]
@field_validator('base_url')
@classmethod
def validate_base_url(cls, value: str) -> str:
normalized = value.rstrip('/')
if not normalized.startswith('https://'):
raise ValueError('Cloud model gateway base URL must use HTTPS')
return normalized
@field_validator('models')
@classmethod
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
if len(value) > 500:
raise ValueError('Cloud model catalog exceeds 500 models')
identities = {(item.category, item.uuid) for item in value}
if len(identities) != len(value):
raise ValueError('Cloud model catalog contains duplicate model identities')
return value
@field_validator('workspaces')
@classmethod
def validate_workspaces(
cls, value: tuple[CloudWorkspaceModelBilling, ...]
) -> tuple[CloudWorkspaceModelBilling, ...]:
if len(value) > 10_000:
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
identities = {item.workspace_uuid for item in value}
if len(identities) != len(value):
raise ValueError('Cloud model catalog contains duplicate Workspaces')
return value
@runtime_checkable
class CloudModelCatalogProvider(Protocol):
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
"""Fetch and verify the complete model catalog and Workspace billing projection."""
...
def system_provider_uuid(workspace_uuid: str) -> str:
workspace = str(uuid.UUID(workspace_uuid))
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
workspace = str(uuid.UUID(workspace_uuid))
if category not in _SUPPORTED_CATEGORIES:
raise ValueError(f'Unsupported model category: {category}')
if not upstream_uuid:
raise ValueError('Upstream model UUID is required')
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
class CloudModelCatalogSyncService:
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
def __init__(
self,
ap: Any,
provider: CloudModelCatalogProvider,
instance_uuid: str,
*,
sync_interval_seconds: float = 3600.0,
) -> None:
if not isinstance(provider, CloudModelCatalogProvider):
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
if sync_interval_seconds < 10:
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
self.ap = ap
self.provider = provider
self.instance_uuid = instance_uuid
self.sync_interval_seconds = float(sync_interval_seconds)
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
# convergence marker so a failed runtime reload is retried even when the
# following database reconciliation is a no-op.
self._runtime_reload_pending = False
self._workspace_credits: dict[str, int | None] = {}
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
"""Return the latest signed owner-credit projection for a Workspace."""
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
async def initialize(self) -> None:
await self.sync_once(reload_runtime=False)
async def run(self) -> None:
while True:
await asyncio.sleep(self.sync_interval_seconds)
try:
await self.sync_once(reload_runtime=True)
except asyncio.CancelledError:
raise
except Exception as exc:
# Exception messages can contain rendered SQL bound values,
# including provider API keys. Log only the exception class.
self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')
async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
snapshot: CloudModelCatalogSnapshot | None = None
sync_error: Exception | None = None
reload_error: Exception | None = None
try:
snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
if snapshot.instance_uuid != self.instance_uuid:
raise ValueError('Cloud model catalog targets another LangBot instance')
bindings = await self.ap.workspace_service.list_active_execution_bindings()
billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
missing = sorted(
binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
)
if missing:
raise ValueError(
f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
)
for binding in bindings:
counts = await self._sync_workspace(
binding.workspace_uuid,
snapshot,
billing_by_workspace[binding.workspace_uuid],
)
summary['workspaces'] += 1
workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
if workspace_changed:
# _sync_workspace returns only after its tenant UoW commits.
self._runtime_reload_pending = True
for key in ('created', 'updated', 'deleted'):
summary[key] += counts[key]
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
except Exception as exc:
sync_error = exc
finally:
model_mgr = getattr(self.ap, 'model_mgr', None)
if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
try:
await model_mgr.load_models_from_db()
except Exception as exc:
reload_error = exc
else:
self._runtime_reload_pending = False
if sync_error is not None:
if reload_error is not None:
raise sync_error from reload_error
raise sync_error
if reload_error is not None:
raise reload_error
changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
if changed and snapshot is not None:
self.ap.logger.info(
'Cloud model catalog synchronized '
f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
)
return summary
async def _sync_workspace(
self,
workspace_uuid: str,
snapshot: CloudModelCatalogSnapshot,
billing: CloudWorkspaceModelBilling,
) -> dict[str, int]:
counts = {'created': 0, 'updated': 0, 'deleted': 0}
provider_uuid = system_provider_uuid(workspace_uuid)
desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
provider = await uow.session.scalar(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
provider_values = {
'workspace_uuid': workspace_uuid,
'name': LANGBOT_MODELS_PROVIDER_NAME,
'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
'base_url': snapshot.base_url,
'api_keys': desired_keys,
}
if provider is None:
provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
uow.session.add(provider)
await uow.session.flush()
counts['created'] += 1
elif self._update_entity(provider, provider_values):
counts['updated'] += 1
existing_by_table: dict[type, dict[str, Any]] = {}
for table in _MODEL_TABLES:
rows = (
await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
).all()
existing_by_table[table] = {row.uuid: row for row in rows}
desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
for item in snapshot.models:
table, values = self._model_values(workspace_uuid, provider_uuid, item)
model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
desired_ids[table].add(model_uuid)
existing = existing_by_table[table].get(model_uuid)
if existing is None:
uow.session.add(table(uuid=model_uuid, **values))
counts['created'] += 1
elif self._update_entity(existing, values):
counts['updated'] += 1
for table, entities in existing_by_table.items():
for model_uuid, entity in entities.items():
if model_uuid not in desired_ids[table]:
await uow.session.delete(entity)
counts['deleted'] += 1
return counts
@staticmethod
def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
changed = False
for key, value in values.items():
if getattr(entity, key) != value:
setattr(entity, key, value)
changed = True
return changed
@staticmethod
def _model_values(
workspace_uuid: str,
provider_uuid: str,
item: CloudModelCatalogItem,
) -> tuple[type, dict[str, Any]]:
ranking = 100 - item.featured_order if item.is_featured else 0
common = {
'workspace_uuid': workspace_uuid,
'name': item.model_id,
'provider_uuid': provider_uuid,
'extra_args': {},
'prefered_ranking': ranking,
}
if item.category == 'chat':
return persistence_model.LLMModel, {
**common,
'abilities': list(item.llm_abilities),
'context_length': None,
}
if item.category == 'embedding':
return persistence_model.EmbeddingModel, common
if item.category == 'rerank':
return persistence_model.RerankModel, common
raise ValueError(f'Unsupported model category: {item.category}')
+3 -9
View File
@@ -54,7 +54,6 @@ from ..cloud import launch as cloud_launch_module
from ..cloud import support_admin as cloud_support_admin_module
from ..cloud import directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_module
from ..cloud import model_catalog as cloud_model_catalog_module
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -143,12 +142,13 @@ class Application:
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
vector_db_mgr: vectordb_mgr.VectorDBManager = None
http_ctrl: http_controller.HTTPController = None
@@ -306,12 +306,6 @@ class Application:
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
if self.cloud_model_catalog_service is not None:
self.task_mgr.create_task(
self.cloud_model_catalog_service.run(),
name='cloud-model-catalog-sync',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
if self.manifest_refresh_service is not None:
self.task_mgr.create_task(
self.manifest_refresh_service.run(),
-11
View File
@@ -46,7 +46,6 @@ from ...cloud import support_admin as cloud_support_admin_module
from ...cloud.directory import directory_projection_limits_from_config
from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver
from ...cloud.model_catalog import CloudModelCatalogSyncService
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.http.authz import WorkspaceRequiredError
@@ -177,16 +176,6 @@ class BuildAppStage(stage.BootingStage):
# of repeating tenant validation for every manager.
await workspace_service_inst.prime_startup_execution_bindings()
if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment):
raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment')
cloud_model_catalog_service = CloudModelCatalogSyncService(
ap,
deployment.model_catalog_provider,
constants.instance_id,
)
await cloud_model_catalog_service.initialize()
ap.cloud_model_catalog_service = cloud_model_catalog_service
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
ap,
workspace_service_inst,
@@ -163,13 +163,6 @@ class WorkspaceMembership(Base):
__table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
sqlalchemy.Index(
'uq_workspace_memberships_one_active_owner',
'workspace_uuid',
unique=True,
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
),
sqlalchemy.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role',
@@ -1,57 +0,0 @@
"""add durable replay protection for signed Space launch assertions
Revision ID: 0016_space_launch_replay
Revises: 0015_cloud_core_collab
Create Date: 2026-07-31
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0016_space_launch_replay'
down_revision = '0015_cloud_core_collab'
branch_labels = None
depends_on = None
_TABLE = 'space_launch_assertion_consumptions'
_POLICY = 'langbot_directory_projection'
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
def upgrade() -> None:
conn = op.get_bind()
if _TABLE not in set(sa.inspect(conn).get_table_names()):
op.create_table(
_TABLE,
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('jti', sa.String(255), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
)
op.create_index(
'ix_space_launch_assertion_consumptions_expiry',
_TABLE,
['instance_uuid', 'expires_at'],
unique=False,
)
if conn.dialect.name == 'postgresql':
table = conn.dialect.identifier_preparer.quote(_TABLE)
policy = conn.dialect.identifier_preparer.quote(_POLICY)
expression = f'instance_uuid::text = {_SETTING}'
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
def downgrade() -> None:
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
op.drop_table(_TABLE)
@@ -1,21 +0,0 @@
"""merge the published Space launch replay and main migration branches
Revision ID: 0018_merge_launch_replay
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
Create Date: 2026-08-01
"""
from __future__ import annotations
revision = '0018_merge_launch_replay'
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,80 +0,0 @@
"""enforce one active owner per Workspace
Revision ID: 0019_single_workspace_owner
Revises: 0018_merge_launch_replay
Create Date: 2026-08-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0019_single_workspace_owner'
down_revision = '0018_merge_launch_replay'
branch_labels = None
depends_on = None
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'workspace_memberships' not in inspector.get_table_names():
return
# Ownership transfer used to promote a second member without demoting the
# original owner. Preserve the Workspace creator where possible and demote
# every historical extra owner before installing the database invariant.
op.execute(
sa.text(
"""
WITH ranked_owners AS (
SELECT membership.uuid,
ROW_NUMBER() OVER (
PARTITION BY membership.workspace_uuid
ORDER BY
CASE
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
ELSE 1
END,
COALESCE(membership.joined_at, membership.created_at),
membership.uuid
) AS owner_rank
FROM workspace_memberships AS membership
JOIN workspaces AS workspace
ON workspace.uuid = membership.workspace_uuid
WHERE membership.role = 'owner'
AND membership.status = 'active'
)
UPDATE workspace_memberships
SET role = 'admin'
WHERE uuid IN (
SELECT uuid
FROM ranked_owners
WHERE owner_rank > 1
)
"""
)
)
# Fresh installations may already have this index because SQLAlchemy
# metadata is created before Alembic advances the revision marker.
op.execute(
sa.text(
'CREATE UNIQUE INDEX IF NOT EXISTS '
'uq_workspace_memberships_one_active_owner '
'ON workspace_memberships (workspace_uuid) '
"WHERE role = 'owner' AND status = 'active'"
)
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'workspace_memberships' not in inspector.get_table_names():
return
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
if _INDEX_NAME in index_names:
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
+1 -1
View File
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase,
+3 -1
View File
@@ -132,7 +132,9 @@ class Controller:
break
if not selected_query: # 找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
if selected_query: # 找到
queries.remove(selected_query)
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
await self.ap.query_pool.condition.wait()
continue
@@ -707,37 +707,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if len(listener_tasks) >= 100:
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
return
listener = typing.cast(
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
listeners[event.__class__],
)
async def run_listener():
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
await listener(event, callback_adapter)
finally:
_current_pipeline_uuid.reset(token)
listener_coro = run_listener()
task_manager = getattr(self.ap, 'task_mgr', None)
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
listener_task = asyncio.create_task(listener_coro)
else:
listener_task = task_manager.create_task(
listener_coro,
kind='websocket-message',
name=f'websocket-message-{connection.connection_id}',
scopes=[
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
instance_uuid=connection.instance_uuid,
workspace_uuid=connection.workspace_uuid,
placement_generation=connection.placement_generation,
).task
listener_tasks.add(listener_task)
listener_task.add_done_callback(self._listener_task_done)
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
task_manager = getattr(self.ap, 'task_mgr', None)
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
else:
listener_task = task_manager.create_task(
listeners[event.__class__](event, callback_adapter),
kind='websocket-message',
name=f'websocket-message-{connection.connection_id}',
scopes=[
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
instance_uuid=connection.instance_uuid,
workspace_uuid=connection.workspace_uuid,
placement_generation=connection.placement_generation,
).task
listener_tasks.add(listener_task)
listener_task.add_done_callback(self._listener_task_done)
finally:
_current_pipeline_uuid.reset(token)
def get_websocket_messages(
self,
-20
View File
@@ -37,7 +37,6 @@ class WorkspaceResourceSnapshot(typing.TypedDict):
extension_count: int
skill_count: int
adapters: list[str]
execution_generation: int
async def _count(
@@ -82,7 +81,6 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
'extension_count': 0,
'skill_count': 0,
'adapters': [],
'execution_generation': binding.placement_generation,
}
for binding in bindings
}
@@ -120,7 +118,6 @@ async def build_heartbeat_payload(
ap: core_app.Application,
*,
workspace_uuid: str,
workspace_create_ts: int = 0,
workspace_resource: WorkspaceResourceSnapshot | None = None,
) -> dict:
"""Collect one anonymous Workspace profile snapshot."""
@@ -213,9 +210,7 @@ async def build_heartbeat_payload(
'event_type': 'instance_heartbeat',
'query_id': '',
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'workspace_uuid': workspace_uuid,
'workspace_create_ts': workspace_create_ts,
'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition,
'features': features,
@@ -223,24 +218,10 @@ async def build_heartbeat_payload(
}
def _workspace_created_timestamp(created_at: datetime | None) -> int:
if created_at is None:
return 0
if created_at.tzinfo is None:
# SQLAlchemy may return persisted UTC values without tzinfo. Never
# reinterpret them in the host's local timezone.
created_at = created_at.replace(tzinfo=timezone.utc)
return int(created_at.timestamp())
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
"""Build one heartbeat per active Workspace."""
bindings = await ap.workspace_service.list_active_execution_bindings()
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
workspace_create_ts = {
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
for binding in bindings
}
resources = {
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
}
@@ -248,7 +229,6 @@ async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
await build_heartbeat_payload(
ap,
workspace_uuid=workspace_uuid,
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
workspace_resource=resources.get(workspace_uuid),
)
for workspace_uuid in workspace_uuids
+2 -8
View File
@@ -4,19 +4,13 @@ import typing
class WorkspaceExecutionContext(typing.Protocol):
@property
def instance_uuid(self) -> str: ...
@property
def workspace_uuid(self) -> str: ...
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
"""Build both first-class telemetry identities for one execution."""
instance_id = execution_context.instance_uuid.strip()
"""Build the canonical telemetry identity for one Workspace execution."""
workspace_uuid = execution_context.workspace_uuid.strip()
if not instance_id:
raise ValueError('Telemetry execution instance ID is empty')
if not workspace_uuid:
raise ValueError('Telemetry execution Workspace UUID is empty')
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
return {'workspace_uuid': workspace_uuid}
+5 -24
View File
@@ -136,31 +136,12 @@ class TelemetryManager:
try:
# Use asyncio.wait_for to ensure we always bound the total time
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
headers: dict[str, str] = {}
if telemetry_token:
headers['X-LangBot-Telemetry-Token'] = telemetry_token
else:
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
user_service = getattr(self.ap, 'user_service', None)
if workspace_uuid and user_service is not None:
try:
owner = await user_service.get_workspace_owner(workspace_uuid)
owner_email = str(getattr(owner, 'user', '') or '').strip()
space_service = getattr(self.ap, 'space_service', None)
access_token = (
await space_service.get_valid_access_token(owner_email)
if owner_email and space_service is not None
else None
)
access_token = str(access_token or '').strip()
if access_token:
headers['Authorization'] = f'Bearer {access_token}'
except Exception:
self.ap.logger.debug(
'Could not resolve authenticated telemetry reporter', exc_info=True
)
if headers:
request = client.post(url, json=sanitized, headers=headers)
request = client.post(
url,
json=sanitized,
headers={'X-LangBot-Telemetry-Token': telemetry_token},
)
else:
request = client.post(url, json=sanitized)
resp = await asyncio.wait_for(request, timeout=10 + 1)
+26 -13
View File
@@ -88,7 +88,6 @@ class ResolvedWorkspaceAccess:
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceMemberView:
membership: WorkspaceMembership
display_name: str
email: str
@@ -295,7 +294,7 @@ class WorkspaceCollaborationService:
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
await self._load_actor(active_session, workspace_uuid, actor)
statement = (
sqlalchemy.select(WorkspaceMembership, User.user, User.normalized_email)
sqlalchemy.select(WorkspaceMembership, User.user)
.join(User, User.uuid == WorkspaceMembership.account_uuid)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
@@ -305,12 +304,8 @@ class WorkspaceCollaborationService:
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
)
return [
WorkspaceMemberView(
membership=membership,
display_name=display_name,
email=email,
)
for membership, display_name, email in (await active_session.execute(statement)).all()
WorkspaceMemberView(membership=membership, email=email)
for membership, email in (await active_session.execute(statement)).all()
]
return await self._run(operation, session=session, read_only=True)
@@ -611,8 +606,6 @@ class WorkspaceCollaborationService:
) -> WorkspaceMembership:
if role not in {item.value for item in MembershipRole}:
raise MembershipPermissionError('Unknown Workspace role')
if role == MembershipRole.OWNER.value:
raise MembershipPermissionError('Workspace ownership cannot be transferred')
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_active_workspace(active_session, workspace_uuid)
@@ -624,8 +617,8 @@ class WorkspaceCollaborationService:
target_account_uuid,
)
self._require_can_manage_target(persisted_actor, target, new_role=role)
if target.role == MembershipRole.OWNER.value:
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
target.role = role
await active_session.flush()
return target
@@ -651,7 +644,7 @@ class WorkspaceCollaborationService:
)
self._require_can_manage_target(persisted_actor, target)
if target.role == MembershipRole.OWNER.value:
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
target.status = MembershipStatus.REMOVED.value
await active_session.flush()
return target
@@ -758,6 +751,26 @@ class WorkspaceCollaborationService:
raise WorkspaceNotFoundError('Workspace not found')
return persisted_actor
async def _require_another_owner(
self,
session: AsyncSession,
workspace_uuid: str,
excluded_account_uuid: str,
) -> None:
owners = (
await session.scalars(
sqlalchemy.select(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
WorkspaceMembership.role == MembershipRole.OWNER.value,
)
.with_for_update()
)
).all()
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
raise WorkspaceNotFoundError('Workspace not found')
-2
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import datetime
from dataclasses import dataclass
@@ -13,4 +12,3 @@ class WorkspaceExecutionBinding:
placement_generation: int
write_fenced: bool
state: str
workspace_created_at: datetime.datetime | None = None
-1
View File
@@ -283,7 +283,6 @@ class WorkspaceService:
placement_generation=execution_state.active_generation,
write_fenced=execution_state.write_fenced,
state=execution_state.state,
workspace_created_at=workspace.created_at,
)
binding = await self._run(operation, session=session)
-24
View File
@@ -9,8 +9,6 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
from __future__ import annotations
from types import SimpleNamespace
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -306,34 +304,12 @@ class TestUserInitEndpoint:
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'password_login_enabled': True,
'space_login_enabled': False,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud(
self, quart_test_client, fake_api_app
):
fake_api_app.deployment = SimpleNamespace(mode='cloud')
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_login_capabilities = AsyncMock(
return_value={'password_login_enabled': True, 'space_login_enabled': True}
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
}
@pytest.mark.asyncio
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
fake_api_app.user_service.is_initialized.return_value = True
@@ -333,6 +333,7 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
assert Permission.RESOURCE_MANAGE.value in permissions
assert not permissions.intersection(
{
Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value,
+2 -40
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlsplit
@@ -15,7 +14,6 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
@pytest.fixture
@@ -60,12 +58,6 @@ async def space_oauth_api():
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.workspace_service.get_execution_binding = AsyncMock(
return_value=SimpleNamespace(
workspace_uuid=WORKSPACE_UUID,
workspace_created_at=WORKSPACE_CREATED_AT,
)
)
application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
)
@@ -242,11 +234,7 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with(
'oauth-code',
[WORKSPACE_UUID],
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
)
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
@pytest.mark.asyncio
@@ -284,10 +272,9 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
assert (await response.get_json())['data'] == {
'credits': 25000,
'owner_space_bound': True,
'is_workspace_owner': True,
@@ -295,31 +282,6 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
@pytest.mark.asyncio
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
application.space_service.get_credits = AsyncMock()
application.cloud_model_catalog_service = SimpleNamespace(
get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
)
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
'credits': 25000,
'owner_space_bound': True,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_not_awaited()
@pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api
-34
View File
@@ -188,7 +188,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
workspace_uuid = current['workspace']['uuid']
assert current['membership']['role'] == 'owner'
assert 'member.invite' in current['permissions']
assert 'owner.transfer' not in current['permissions']
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
@@ -264,14 +263,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert member_current['membership']['role'] == 'viewer'
assert 'member.invite' not in member_current['permissions']
transfer_response = await client.patch(
f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
headers=_auth(owner_token, workspace_uuid),
json={'role': 'owner'},
)
assert transfer_response.status_code == 403
assert (await transfer_response.get_json())['code'] == 'permission_denied'
forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(member_token, workspace_uuid),
@@ -281,31 +272,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
async def test_workspace_member_list_returns_display_name_and_email(workspace_api):
_, client, engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
current = (await current_response.get_json())['data']
workspace_uuid = current['workspace']['uuid']
owner_uuid = current['membership']['account_uuid']
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(User).where(User.uuid == owner_uuid).values(user='Owner Display Name')
)
response = await client.get(
f'/api/v1/workspaces/{workspace_uuid}/members',
headers=_auth(owner_token, workspace_uuid),
)
assert response.status_code == 200
members = (await response.get_json())['data']['members']
assert len(members) == 1
assert members[0]['display_name'] == 'Owner Display Name'
assert members[0]['email'] == 'owner@example.com'
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
_, client, _, owner_token = workspace_api
@@ -95,18 +95,6 @@ class TestSQLiteMigrationBaseline:
class TestSQLiteMigrationUpgrade:
"""Tests for upgrade to head workflow."""
@pytest.mark.asyncio
async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine):
"""A database released at the production-only 0016 head must remain upgradable."""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0019_single_workspace_owner'
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
"""
@@ -1,100 +0,0 @@
from __future__ import annotations
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
@pytest.mark.asyncio
async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
session_factory = async_sessionmaker(engine, expire_on_commit=False)
workspace_uuid = '00000000-0000-4000-8000-000000000001'
creator_uuid = '00000000-0000-4000-8000-000000000010'
promoted_uuid = '00000000-0000-4000-8000-000000000020'
async with session_factory() as session:
session.add_all(
[
User(
uuid=creator_uuid,
user='creator@example.test',
normalized_email='creator@example.test',
password='hash',
account_type='local',
),
User(
uuid=promoted_uuid,
user='promoted@example.test',
normalized_email='promoted@example.test',
password='hash',
account_type='local',
),
Workspace(
uuid=workspace_uuid,
instance_uuid='instance-test',
name='Workspace',
slug='workspace',
type='team',
status='active',
source='local',
created_by_account_uuid=creator_uuid,
),
WorkspaceMembership(
uuid='00000000-0000-4000-8000-000000000100',
workspace_uuid=workspace_uuid,
account_uuid=creator_uuid,
role='owner',
status='active',
),
WorkspaceMembership(
uuid='00000000-0000-4000-8000-000000000200',
workspace_uuid=workspace_uuid,
account_uuid=promoted_uuid,
role='owner',
status='active',
),
]
)
await session.commit()
await run_alembic_stamp(engine, '0018_merge_launch_replay')
await run_alembic_upgrade(engine, 'head')
async with engine.connect() as connection:
roles = dict(
(
await connection.execute(
sa.text(
'SELECT account_uuid, role FROM workspace_memberships '
'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
),
{'workspace_uuid': workspace_uuid},
)
).all()
)
assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
indexes = await connection.run_sync(
lambda sync_connection: {
index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
}
)
assert 'uq_workspace_memberships_one_active_owner' in indexes
with pytest.raises(sa.exc.IntegrityError):
async with engine.begin() as connection:
await connection.execute(
sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
{'account_uuid': promoted_uuid},
)
finally:
await engine.dispose()
+2 -1
View File
@@ -27,9 +27,10 @@ def test_owner_has_every_fixed_permission():
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
def test_admin_cannot_delete_workspace_or_link_billing():
def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
ctx = _context(authz.WorkspaceRole.ADMIN)
assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
@@ -1,112 +0,0 @@
"""Cloud Runtime write protection for the managed LangBot Models catalog."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.api.http.service import model as model_service_module
from langbot.pkg.api.http.service.model import (
EmbeddingModelsService,
LLMModelsService,
RerankModelsService,
_assert_cloud_managed_provider_mutable,
)
from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
WORKSPACE = 'workspace-a'
PROVIDER = 'managed-provider'
MODEL = 'managed-model'
@pytest.mark.asyncio
async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None:
async def managed_provider(_ap, _context, provider_uuid):
assert provider_uuid == PROVIDER
return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}
monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider)
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
with pytest.raises(ValueError, match='managed by Cloud'):
await _assert_cloud_managed_provider_mutable(
application,
WORKSPACE,
PROVIDER,
)
application.persistence_mgr.mode.value = 'normal'
await _assert_cloud_managed_provider_mutable(
application,
WORKSPACE,
PROVIDER,
)
@pytest.mark.parametrize(
('service_type', 'create_method', 'model_data'),
[
(LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}),
(EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}),
(RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}),
],
)
@pytest.mark.asyncio
async def test_all_model_types_reject_creation_under_managed_provider(
monkeypatch,
service_type,
create_method: str,
model_data: dict,
) -> None:
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
application = SimpleNamespace(
persistence_mgr=SimpleNamespace(),
provider_service=SimpleNamespace(
get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER})
),
model_mgr=None,
)
service = service_type(application)
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, create_method)(WORKSPACE, model_data)
guard.assert_awaited_once()
@pytest.mark.parametrize(
('service_type', 'get_method', 'write_method', 'payload'),
[
(LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}),
(LLMModelsService, 'get_llm_model', 'delete_llm_model', None),
(EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}),
(EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None),
(RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}),
(RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None),
],
)
@pytest.mark.asyncio
async def test_all_model_types_reject_update_and_delete_for_managed_provider(
monkeypatch,
service_type,
get_method: str,
write_method: str,
payload: dict | None,
) -> None:
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
service = service_type(application)
monkeypatch.setattr(
service,
get_method,
AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}),
)
args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload)
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, write_method)(*args)
guard.assert_awaited_once()
@@ -25,7 +25,6 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
WORKSPACE_UUID = 'workspace-a'
SYSTEM_REQUESTER = 'space-chat-completions'
def _create_mock_provider(
@@ -1006,56 +1005,3 @@ class TestProviderSecretRoundtrip:
)
ap.persistence_mgr.execute_async.assert_not_awaited()
class TestCloudManagedProviderProtection:
@staticmethod
def _service() -> ModelProviderService:
ap = SimpleNamespace(
persistence_mgr=SimpleNamespace(
mode=SimpleNamespace(value='cloud_runtime'),
execute_async=AsyncMock(),
),
model_mgr=SimpleNamespace(),
)
return ModelProviderService(ap)
async def test_cloud_rejects_user_created_system_requester(self):
service = self._service()
with pytest.raises(ValueError, match='reserved'):
await service.create_provider(
WORKSPACE_UUID,
{
'name': 'Fake LangBot Models',
'requester': SYSTEM_REQUESTER,
'base_url': 'https://example.invalid/v1',
'api_keys': ['fake'],
},
)
with pytest.raises(ValueError, match='reserved'):
await service.find_or_create_provider(
WORKSPACE_UUID,
SYSTEM_REQUESTER,
'https://api.langbot.cloud/v1',
['fake'],
)
service.ap.persistence_mgr.execute_async.assert_not_awaited()
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
service = self._service()
service.get_provider = AsyncMock(
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
)
with pytest.raises(ValueError, match='managed by Cloud'):
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
with pytest.raises(ValueError, match='managed by Cloud'):
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
service.ap.persistence_mgr.execute_async.assert_not_awaited()
async def test_oss_does_not_reserve_space_requester(self):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
service = ModelProviderService(ap)
assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False
@@ -25,7 +25,6 @@ import time
from langbot.pkg.api.http.service.space import SpaceService
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.utils import constants
pytestmark = pytest.mark.asyncio
@@ -574,20 +573,10 @@ class TestSpaceServiceExchangeOAuthCode:
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute
result = await service.exchange_oauth_code(
'auth_code',
['workspace-1'],
{'workspace-1': 1_700_000_000},
)
result = await service.exchange_oauth_code('auth_code')
# Verify
assert result['access_token'] == 'new_access_token'
assert mock_session_obj.post.call_args.kwargs['json'] == {
'code': 'auth_code',
'instance_id': constants.instance_id,
'workspace_uuids': ['workspace-1'],
'workspace_created_ats': {'workspace-1': 1_700_000_000},
}
async def test_exchange_oauth_code_api_error(self):
"""Raises ValueError on API error."""
+1 -19
View File
@@ -66,10 +66,6 @@ class _Provider:
def __init__(self):
self.manifest_provider = _Manifest()
async def fetch_model_catalog(self, instance_uuid: str):
del instance_uuid
raise AssertionError('not used by bootstrap contract tests')
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
del instance_config
return VerifiedCloudDeployment(
@@ -83,7 +79,6 @@ class _Provider:
entitlement_provider=_Entitlements(),
directory_provider=_Directory(),
manifest_provider=self.manifest_provider,
model_catalog_provider=self,
verification_key_id='root-2026',
)
@@ -233,23 +228,10 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
)
async def test_cloud_runtime_allows_explicitly_disabled_box():
config = _cloud_config()
config['box']['enabled'] = False
deployment = await resolve_deployment(
instance_uuid='instance-a',
instance_config=config,
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
now=1_000,
)
assert isinstance(deployment, VerifiedCloudDeployment)
@pytest.mark.parametrize(
('mutate', 'message'),
[
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
(
@@ -1,378 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.cloud.model_catalog import (
CloudModelCatalogSnapshot,
CloudModelCatalogSyncService,
system_model_uuid,
system_provider_uuid,
)
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
pytestmark = pytest.mark.asyncio
INSTANCE_UUID = 'instance-model-catalog'
WORKSPACE_A = '00000000-0000-4000-8000-000000000001'
WORKSPACE_B = '00000000-0000-4000-8000-000000000002'
OWNER_A = '10000000-0000-4000-8000-000000000001'
OWNER_B = '10000000-0000-4000-8000-000000000002'
class _CatalogProvider:
def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None:
self.snapshot = snapshot
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
assert instance_uuid == INSTANCE_UUID
return self.snapshot
def _snapshot(
*,
key_a: str | None = 'owner-a-key',
model_id: str = 'gpt-test',
include_embedding: bool = True,
) -> CloudModelCatalogSnapshot:
models = [
{
'uuid': 'upstream-chat',
'model_id': model_id,
'category': 'chat',
'llm_abilities': ['chat', 'vision'],
'is_featured': True,
'featured_order': 7,
}
]
if include_embedding:
models.append(
{
'uuid': 'upstream-embedding',
'model_id': 'embedding-test',
'category': 'embedding',
}
)
return CloudModelCatalogSnapshot.model_validate(
{
'instance_uuid': INSTANCE_UUID,
'generated_at': datetime.now(UTC),
'base_url': 'https://api.langbot.cloud/v1/',
'models': models,
'workspaces': [
{
'workspace_uuid': WORKSPACE_A,
'owner_account_uuid': OWNER_A,
'api_key': key_a,
'credits': 25000,
},
{
'workspace_uuid': WORKSPACE_B,
'owner_account_uuid': OWNER_B,
'api_key': 'owner-b-key',
'credits': 5000,
},
],
}
)
async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None:
payload = _snapshot().model_dump(mode='json')
payload['models'][0]['llm_abilities'] = None
snapshot = CloudModelCatalogSnapshot.model_validate(payload)
assert snapshot.models[0].llm_abilities == ()
async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
bindings = [
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1),
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
]
workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings))
reload_counter = _AsyncCounter()
runtime_reload = SimpleNamespace(load_models_from_db=reload_counter)
app = SimpleNamespace(
persistence_mgr=manager,
workspace_service=workspace_service,
model_mgr=runtime_reload,
logger=logging.getLogger(__name__),
)
provider = _CatalogProvider(_snapshot())
service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID)
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(
sqlalchemy.insert(Workspace),
[
{
'uuid': WORKSPACE_A,
'instance_uuid': INSTANCE_UUID,
'name': 'A',
'slug': 'a',
'source': 'cloud_projection',
},
{
'uuid': WORKSPACE_B,
'instance_uuid': INSTANCE_UUID,
'name': 'B',
'slug': 'b',
'source': 'cloud_projection',
},
],
)
await connection.execute(
sqlalchemy.insert(ModelProvider).values(
uuid='custom-provider',
workspace_uuid=WORKSPACE_A,
name='Custom',
requester='openai-chat-completions',
base_url='https://custom.example/v1',
api_keys=['custom-key'],
)
)
await connection.execute(
sqlalchemy.insert(LLMModel).values(
uuid='custom-model',
workspace_uuid=WORKSPACE_A,
name='custom-model',
provider_uuid='custom-provider',
abilities=['chat'],
extra_args={},
prefered_ranking=0,
)
)
first = await service.sync_once()
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
assert reload_counter.calls == 1
assert service.get_workspace_credits(WORKSPACE_A) == 25000
assert service.get_workspace_credits(WORKSPACE_B) == 5000
async with engine.connect() as connection:
providers = (
await connection.execute(
sqlalchemy.select(
ModelProvider.uuid,
ModelProvider.workspace_uuid,
ModelProvider.api_keys,
).where(ModelProvider.requester == 'space-chat-completions')
)
).all()
assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B}
assert {item.uuid for item in providers} == {
system_provider_uuid(WORKSPACE_A),
system_provider_uuid(WORKSPACE_B),
}
assert {item.workspace_uuid: item.api_keys for item in providers} == {
WORKSPACE_A: ['owner-a-key'],
WORKSPACE_B: ['owner-b-key'],
}
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2
second = await service.sync_once()
assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0}
assert reload_counter.calls == 1
provider.snapshot = _snapshot(
key_a='new-owner-key',
model_id='gpt-renamed',
include_embedding=False,
)
third = await service.sync_once()
assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2}
assert reload_counter.calls == 2
async with engine.connect() as connection:
provider_a_keys = await connection.scalar(
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
)
assert provider_a_keys == ['new-owner-key']
system_model_names = (
(
await connection.execute(
sqlalchemy.select(LLMModel.name).where(
LLMModel.provider_uuid.in_(
[system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)]
)
)
)
)
.scalars()
.all()
)
assert set(system_model_names) == {'gpt-renamed'}
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0
assert (
await connection.scalar(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(ModelProvider)
.where(ModelProvider.uuid == 'custom-provider')
)
== 1
)
assert (
await connection.scalar(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(LLMModel)
.where(LLMModel.uuid == 'custom-model')
)
== 1
)
provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False)
fourth = await service.sync_once()
assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0}
assert reload_counter.calls == 3
async with engine.connect() as connection:
provider_a_keys = await connection.scalar(
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
)
assert provider_a_keys == []
finally:
await engine.dispose()
def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None:
assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A)
assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B)
assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream')
snapshot = _snapshot()
assert 'owner-a-key' not in repr(snapshot)
async def test_snapshot_must_cover_every_active_workspace() -> None:
snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]})
app = SimpleNamespace(
workspace_service=SimpleNamespace(
list_active_execution_bindings=lambda: _async_value(
[SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)]
)
),
logger=logging.getLogger(__name__),
)
service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID)
with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'):
await service.sync_once()
async def _async_value(value):
return value
class _AsyncCounter:
def __init__(self) -> None:
self.calls = 0
async def __call__(self) -> None:
self.calls += 1
async def test_partial_workspace_failure_reloads_already_committed_changes() -> None:
bindings = [
SimpleNamespace(workspace_uuid=WORKSPACE_A),
SimpleNamespace(workspace_uuid=WORKSPACE_B),
]
reload_counter = _AsyncCounter()
app = SimpleNamespace(
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
model_mgr=SimpleNamespace(load_models_from_db=reload_counter),
logger=logging.getLogger(__name__),
)
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
calls = 0
async def sync_workspace(*_args):
nonlocal calls
calls += 1
if calls == 1:
return {'created': 1, 'updated': 0, 'deleted': 0}
raise RuntimeError('second Workspace failed')
service._sync_workspace = sync_workspace # type: ignore[method-assign]
with pytest.raises(RuntimeError, match='second Workspace failed'):
await service.sync_once()
assert service.get_workspace_credits(WORKSPACE_A) == 25000
assert service.get_workspace_credits(WORKSPACE_B) is None
assert reload_counter.calls == 1
async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None:
bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)]
class _FlakyReload:
def __init__(self) -> None:
self.calls = 0
async def __call__(self) -> None:
self.calls += 1
if self.calls == 1:
raise RuntimeError('reload failed')
runtime_reload = _FlakyReload()
app = SimpleNamespace(
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
model_mgr=SimpleNamespace(load_models_from_db=runtime_reload),
logger=logging.getLogger(__name__),
)
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
calls = 0
async def sync_workspace(*_args):
nonlocal calls
calls += 1
if calls == 1:
return {'created': 1, 'updated': 0, 'deleted': 0}
return {'created': 0, 'updated': 0, 'deleted': 0}
service._sync_workspace = sync_workspace # type: ignore[method-assign]
with pytest.raises(RuntimeError, match='reload failed'):
await service.sync_once()
summary = await service.sync_once()
assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0}
assert runtime_reload.calls == 2
async def test_background_sync_log_redacts_exception_message(caplog) -> None:
secret = 'owner-secret-api-key'
attempted = asyncio.Event()
class _FailingProvider:
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
del instance_uuid
attempted.set()
raise RuntimeError(f'database parameters include {secret}')
app = SimpleNamespace(logger=logging.getLogger(__name__))
service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID)
service.sync_interval_seconds = 0.001
task = asyncio.create_task(service.run())
try:
await asyncio.wait_for(attempted.wait(), timeout=1)
await asyncio.sleep(0.01)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert secret not in caplog.text
assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text
@@ -961,7 +961,6 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
sa.select(
sa.func.now(),
sa.func.date_trunc('hour', sa.column('timestamp')),
sa.func.length(sa.literal('value')),
sa.func.nullif(sa.literal('value'), sa.literal('')),
),
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
from langbot.pkg.pipeline.controller import Controller
from langbot.pkg.pipeline.pool import QueryPool
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
@@ -144,31 +143,3 @@ async def test_controller_revalidates_generation_before_running_pipeline(
runtime_pipeline.run.assert_awaited_once_with(sample_query)
query_pool.remove_query.assert_awaited_once_with(sample_query)
session._semaphore.release.assert_called_once_with()
@pytest.mark.asyncio
async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query):
query_pool = QueryPool()
query_pool.queries.append(sample_query)
mock_app.query_pool = query_pool
mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1)))
scheduler_errors: list[str] = []
def stop_on_scheduler_error(message):
scheduler_errors.append(str(message))
raise asyncio.CancelledError
def stop_after_scheduling(process_coro, **_kwargs):
process_coro.close()
raise asyncio.CancelledError
mock_app.logger.error.side_effect = stop_on_scheduler_error
mock_app.task_mgr.create_task.side_effect = stop_after_scheduling
controller = Controller(mock_app)
with pytest.raises(asyncio.CancelledError):
await controller.consumer()
assert scheduler_errors == []
assert query_pool.queries == []
@@ -1,7 +1,6 @@
"""Regression tests for isolated embed-widget conversations."""
import asyncio
import contextvars
from pathlib import Path
from unittest.mock import AsyncMock, Mock
@@ -205,48 +204,6 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
@pytest.mark.asyncio
async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
manager = WebSocketConnectionManager()
connection = await manager.add_connection(
websocket=Mock(),
scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
class DetachedTaskManager:
def __init__(self):
self.tasks = []
def create_task(self, coro, **_kwargs):
task = asyncio.create_task(coro, context=contextvars.Context())
self.tasks.append(task)
return Mock(task=task)
task_manager = DetachedTaskManager()
adapter = WebSocketAdapter.model_construct(
ap=Mock(task_mgr=task_manager),
logger=_adapter_logger(),
)
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
pipeline_overrides = []
async def listener(_event, callback_adapter):
pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override())
adapter.listeners = {platform_events.FriendMessage: listener}
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
)
await asyncio.gather(*task_manager.tasks)
assert pipeline_overrides == ['pipeline-1']
@pytest.mark.asyncio
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
+6 -24
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
@@ -15,12 +14,6 @@ def get_heartbeat_module():
return import_module('langbot.pkg.telemetry.heartbeat')
def test_workspace_created_timestamp_treats_naive_database_values_as_utc():
heartbeat = get_heartbeat_module()
created_at = datetime(2026, 8, 4, 0, 0, 0)
assert heartbeat._workspace_created_timestamp(created_at) == 1785801600
def make_app():
ap = Mock()
ap.instance_config = Mock()
@@ -64,17 +57,15 @@ def make_app():
class TestBuildHeartbeatPayload:
@pytest.mark.asyncio
async def test_payload_shape(self, monkeypatch):
async def test_payload_shape(self):
heartbeat = get_heartbeat_module()
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
ap = make_app()
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
assert payload['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == ''
assert payload['workspace_uuid'] == 'workspace-a'
assert payload['instance_id']
assert payload['workspace_create_ts'] == 0
assert 'instance_id' not in payload
assert 'instance_create_ts' in payload
assert 'timestamp' in payload
f = payload['features']
@@ -109,9 +100,8 @@ class TestBuildHeartbeatPayload:
assert payload['features']['pipeline_count'] == -1
@pytest.mark.asyncio
async def test_cloud_counts_loaded_registries_without_tenant_sql(self, monkeypatch):
async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
heartbeat = get_heartbeat_module()
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
ap = make_app()
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
ap.persistence_mgr.execute_async = AsyncMock(
@@ -149,12 +139,8 @@ class TestBuildHeartbeatPayload:
}
ap.workspace_service.list_active_execution_bindings = AsyncMock(
return_value=[
SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
SimpleNamespace(
workspace_uuid='workspace-b',
placement_generation=9,
workspace_created_at=datetime(2026, 8, 4, tzinfo=timezone.utc),
),
SimpleNamespace(workspace_uuid='workspace-a'),
SimpleNamespace(workspace_uuid='workspace-b'),
],
)
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
@@ -164,9 +150,7 @@ class TestBuildHeartbeatPayload:
payloads = await heartbeat.build_heartbeat_payloads(ap)
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
assert all(payload['instance_id'] for payload in payloads)
assert payloads[0]['workspace_create_ts'] == 0
assert payloads[1]['workspace_create_ts'] == 1785801600
assert all('instance_id' not in payload for payload in payloads)
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
assert by_workspace['workspace-a']['pipeline_count'] == 2
assert by_workspace['workspace-a']['mcp_server_count'] == 3
@@ -175,12 +159,10 @@ class TestBuildHeartbeatPayload:
assert by_workspace['workspace-a']['plugin_count'] == 2
assert by_workspace['workspace-a']['extension_count'] == 5
assert by_workspace['workspace-a']['skill_count'] == 2
assert by_workspace['workspace-a']['execution_generation'] == 7
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
assert by_workspace['workspace-b']['bot_count'] == 1
assert by_workspace['workspace-b']['pipeline_count'] == 0
assert by_workspace['workspace-b']['skill_count'] == 1
assert by_workspace['workspace-b']['execution_generation'] == 9
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
assert 'workspace_resources' not in by_workspace['workspace-a']
ap.persistence_mgr.execute_async.assert_not_awaited()
@@ -596,36 +596,6 @@ class TestTelemetryManagedRuntimeAuthentication:
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
class TestAuthenticatedWorkspaceReporter:
@pytest.mark.asyncio
async def test_workspace_owner_access_token_is_sent_as_bearer(self):
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
mock_app.user_service = Mock()
mock_app.user_service.get_workspace_owner = AsyncMock(
return_value=Mock(user='owner@example.com', space_access_token='expired-token')
)
mock_app.space_service = Mock()
mock_app.space_service.get_valid_access_token = AsyncMock(return_value='refreshed-workspace-owner-token')
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
response = Mock(status_code=200, text='')
response.json = Mock(return_value={'code': 0})
mock_client = Mock()
mock_client.post = Mock(return_value=response)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'q-1', 'workspace_uuid': 'workspace-1'})
mock_app.user_service.get_workspace_owner.assert_awaited_once_with('workspace-1')
mock_app.space_service.get_valid_access_token.assert_awaited_once_with('owner@example.com')
assert mock_client.post.call_args.kwargs['headers'] == {
'Authorization': 'Bearer refreshed-workspace-owner-token'
}
class TestStartSendTask:
"""Tests for start_send_task() method."""
@@ -7,28 +7,25 @@ from types import SimpleNamespace
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
instance_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
assert workspace_uuid_from_instance_id(f'instance_{instance_uuid}') == instance_uuid
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
first = workspace_uuid_from_instance_id('instance_migration_test')
second = workspace_uuid_from_instance_id('instance_migration_test')
first = workspace_uuid_from_instance_id("instance_migration_test")
second = workspace_uuid_from_instance_id("instance_migration_test")
assert first == second
assert str(uuid.UUID(first)) == first
def test_query_telemetry_identity_reports_instance_and_workspace():
def test_query_telemetry_identity_uses_execution_workspace_only():
from langbot.pkg.telemetry.identity import workspace_identity
identity = workspace_identity(SimpleNamespace(workspace_uuid='workspace-a', instance_uuid='instance-a'))
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
assert identity == {
'instance_id': 'instance-a',
'workspace_uuid': 'workspace-a',
}
assert identity == {"workspace_uuid": "workspace-a"}
@@ -203,21 +203,20 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
second_membership,
)
with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
await service.update_member_role(
workspace.uuid,
second.uuid,
'owner',
owner_membership,
)
with pytest.raises(LastOwnerError):
await service.update_member_role(
workspace.uuid,
owner_membership.account_uuid,
'admin',
owner_membership,
)
promoted = await service.update_member_role(
workspace.uuid,
second.uuid,
'owner',
owner_membership,
)
assert promoted.role == 'owner'
demoted = await service.update_member_role(
workspace.uuid,
owner_membership.account_uuid,
'admin',
owner_membership,
)
assert demoted.role == 'admin'
async def test_workspace_selector_requires_membership(collaboration_context):
Generated
+4 -4
View File
@@ -1999,7 +1999,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.7"
version = "4.10.6"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2116,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2182,8 +2182,8 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.0"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=101e453e916b39465a6294d6471c9eaae8725d5c#101e453e916b39465a6294d6471c9eaae8725d5c" }
version = "0.4.18"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162#1d65ed301a6afc52150a998043f73cd6032c8162" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
+4 -52
View File
@@ -5,7 +5,6 @@ import {
beginAuthenticatedSession,
beginSupportAdminSession,
bootstrapWorkspaceSession,
clearPendingInvitationToken,
getPendingInvitationToken,
} from '@/app/infra/http';
import { toast } from 'sonner';
@@ -67,10 +66,6 @@ function SpaceOAuthCallbackContent() {
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const isMountedRef = useRef(true);
const directLaunchFragmentRef = useRef<{
workspaceUuid: string | null;
launchAssertion: string | null;
} | null>(null);
const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error'
@@ -113,31 +108,8 @@ function SpaceOAuthCallbackContent() {
}
beginAuthenticatedSession(response.token, response.user);
const invitationToken = getPendingInvitationToken();
if (invitationToken) {
let invitation;
try {
invitation =
await httpClient.acceptWorkspaceInvitation(invitationToken);
} catch (error) {
const code = (error as { code?: string }).code;
const path = code
? `/invitations/accept?error=${encodeURIComponent(code)}`
: '/invitations/accept';
navigate(path, { replace: true });
return;
}
beginAuthenticatedSession(invitation.token, response.user);
clearPendingInvitationToken();
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: invitation.workspace_uuid,
});
if (workspaceResult.status === 'unavailable') {
navigate('/workspace-unavailable', { replace: true });
return;
}
navigate('/home', { replace: true });
if (getPendingInvitationToken()) {
navigate('/invitations/accept', { replace: true });
return;
}
const workspaceResult = await bootstrapWorkspaceSession({
@@ -248,28 +220,8 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
if (directLaunchFragmentRef.current === null) {
const fragmentParams = new URLSearchParams(
window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash,
);
directLaunchFragmentRef.current = {
workspaceUuid: fragmentParams.get('workspace_uuid'),
launchAssertion: fragmentParams.get('launch_assertion'),
};
if (window.location.hash) {
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`,
);
}
}
const workspaceUuid =
directLaunchFragmentRef.current.workspaceUuid ??
searchParams.get('workspace_uuid');
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
const workspaceUuid = searchParams.get('workspace_uuid');
const launchAssertion = searchParams.get('launch_assertion');
if (error) {
setStatus('error');
+42 -104
View File
@@ -49,8 +49,6 @@ import type {
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
type PopoverView = 'menu' | 'mcp' | 'github';
@@ -156,12 +154,6 @@ function AddExtensionContent() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const { extensions: extensionQuota, extensionsReached } =
useWorkspaceQuotaStatus();
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
resource: t('sidebar.extensions'),
max: extensionQuota.max,
});
// Localized label for an extension type, used in the install dialog.
const extensionTypeLabel = (type: string) =>
@@ -352,28 +344,23 @@ function AddExtensionContent() {
t,
]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (extensionsReached) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
plugin_label: extractI18nObject(plugin.label) || plugin.name,
plugin_description: extractI18nObject(plugin.description) || '',
plugin_icon: plugin.icon || '',
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setInstallIconFailed(false);
setModalOpen(true);
},
[extensionsReached],
);
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
plugin_label: extractI18nObject(plugin.label) || plugin.name,
plugin_description: extractI18nObject(plugin.description) || '',
plugin_icon: plugin.icon || '',
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setInstallIconFailed(false);
setModalOpen(true);
}, []);
function handleModalConfirm() {
if (extensionsReached) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
@@ -415,7 +402,6 @@ function AddExtensionContent() {
const uploadFile = useCallback(
async (file: File) => {
if (extensionsReached) return;
if (!validateFileType(file)) {
toast.error(t('addExtension.unsupportedFileType'));
return;
@@ -435,15 +421,14 @@ function AddExtensionContent() {
setSkillUploadPreviewOpen(true);
}
},
[extensionsReached, t, setSelectedTaskId],
[t, setSelectedTaskId],
);
const handleFileSelect = useCallback(() => {
if (extensionsReached) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, [extensionsReached]);
}, []);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -470,13 +455,12 @@ function AddExtensionContent() {
(event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
if (extensionsReached) return;
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
uploadFile(files[0]);
}
},
[extensionsReached, uploadFile],
[uploadFile],
);
function handleMCPCreated(_serverName: string) {
@@ -506,8 +490,7 @@ function AddExtensionContent() {
return false;
}
} catch {
toast.error(t('limitation.quotaCheckFailed'));
return false;
// If we can't check, let backend handle it
}
return true;
}
@@ -647,11 +630,9 @@ function AddExtensionContent() {
async function handleGithubConfirm() {
if (!selectedAsset || !selectedRelease) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
return;
}
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
@@ -683,11 +664,9 @@ function AddExtensionContent() {
async function handleGithubSkillConfirm() {
if (!githubSkillInfo) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
return;
}
try {
await httpClient.installSkillFromGithub(
githubURL.trim(),
@@ -747,24 +726,17 @@ function AddExtensionContent() {
setPopoverOpen(open);
}}
>
<WorkspaceQuotaTooltip
quota={extensionQuota}
resource={t('sidebar.extensions')}
>
<PopoverTrigger asChild>
<Button
variant="default"
disabled={extensionsReached}
aria-disabled={extensionsReached}
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
>
<PlusIcon className="w-4 h-4" />
<span className="whitespace-nowrap">
{t('addExtension.manualAdd')}
</span>
</Button>
</PopoverTrigger>
</WorkspaceQuotaTooltip>
<PopoverTrigger asChild>
<Button
variant="default"
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
>
<PlusIcon className="w-4 h-4" />
<span className="whitespace-nowrap">
{t('addExtension.manualAdd')}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
forceMount
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
@@ -773,19 +745,9 @@ function AddExtensionContent() {
{/* ===== Menu View ===== */}
{popoverView === 'menu' && (
<div className="space-y-4 p-4">
{extensionsReached && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
{extensionQuotaTooltip}
</div>
)}
{/* File upload area */}
<div
aria-disabled={extensionsReached}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
extensionsReached
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50'
@@ -815,8 +777,7 @@ function AddExtensionContent() {
<div className="space-y-2">
<button
type="button"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('mcp')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -835,8 +796,7 @@ function AddExtensionContent() {
<button
type="button"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setPopoverView('github')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -855,8 +815,7 @@ function AddExtensionContent() {
<button
type="button"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setPopoverOpen(false);
@@ -923,7 +882,6 @@ function AddExtensionContent() {
type="submit"
form="mcp-form"
size="sm"
disabled={extensionsReached}
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
@@ -988,7 +946,6 @@ function AddExtensionContent() {
className="w-full"
onClick={handleGithubAddressSubmit}
disabled={
extensionsReached ||
!githubURL.trim() ||
fetchingReleases ||
fetchingSkillPreview
@@ -1145,11 +1102,7 @@ function AddExtensionContent() {
</div>
</div>
)}
<Button
className="w-full"
onClick={handleGithubConfirm}
disabled={extensionsReached}
>
<Button className="w-full" onClick={handleGithubConfirm}>
{t('common.confirm')}
</Button>
</div>
@@ -1231,7 +1184,6 @@ function AddExtensionContent() {
<Button
className="w-full"
onClick={handleGithubSkillConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
@@ -1288,8 +1240,6 @@ function AddExtensionContent() {
<MarketPage
installPlugin={handleInstallPlugin}
headerActions={extensionActions}
installDisabled={extensionsReached}
installDisabledTooltip={extensionQuotaTooltip}
/>
</div>
</div>
@@ -1375,17 +1325,9 @@ function AddExtensionContent() {
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<WorkspaceQuotaTooltip
quota={extensionQuota}
resource={t('sidebar.extensions')}
>
<Button
onClick={handleModalConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
</WorkspaceQuotaTooltip>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
@@ -1417,8 +1359,6 @@ function AddExtensionContent() {
{pluginUploadPreviewFile && (
<PluginLocalPreviewPanel
file={pluginUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
@@ -1452,8 +1392,6 @@ function AddExtensionContent() {
{skillUploadPreviewFile && (
<SkillZipPreviewPanel
file={skillUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => {
setSkillUploadPreviewOpen(false);
setSkillUploadPreviewFile(null);
@@ -396,9 +396,10 @@ export default function BotForm({
<form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
aria-busy={isLoading}
>
<fieldset className="space-y-6" disabled={isLoading}>
<fieldset className="contents" disabled={isLoading}>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
@@ -8,7 +8,6 @@ import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
@@ -33,6 +32,7 @@ import {
Zap,
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
RefreshCcw,
@@ -109,11 +109,6 @@ import {
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover';
import {
type WorkspaceQuotaItem,
useWorkspaceQuotaStatus,
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
// Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean {
@@ -284,14 +279,6 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
count: 0,
max: -1,
reached: false,
loading: false,
disabled: false,
};
async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
@@ -399,7 +386,6 @@ function NavItems({
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
@@ -543,7 +529,7 @@ function NavItems({
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
@@ -589,18 +575,6 @@ function NavItems({
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const quota =
categoryId === 'bots'
? quotaStatus.bots
: categoryId === 'pipelines'
? quotaStatus.pipelines
: categoryId === 'knowledge'
? quotaStatus.knowledgeBases
: categoryId === 'plugins' ||
categoryId === 'mcp' ||
categoryId === 'skills'
? quotaStatus.extensions
: UNLIMITED_QUOTA;
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
@@ -933,144 +907,128 @@ function NavItems({
>
<div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span>
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
navigate('/home/add-extension');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
</div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)}
@@ -1138,119 +1096,103 @@ function NavItems({
/>
</button>
)}
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
navigate('/home/add-extension');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
))}
<CollapsibleTrigger asChild>
<button
type="button"
@@ -1695,13 +1637,6 @@ export default function HomeSidebar({
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
const showWorkspaceSwitcher =
workspaces.length > 1 ||
currentWorkspace?.workspace.source === 'cloud_projection';
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' &&
currentWorkspace?.permissions.includes('audit.view');
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -1980,11 +1915,9 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarHeader>
{showWorkspaceSwitcher && (
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
)}
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
{/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -2165,16 +2098,15 @@ export default function HomeSidebar({
<UsersRound />
{t('workspace.settings')}
</DropdownMenuItem>
{canViewStorageAnalysis && (
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
{t('storageAnalysis.title')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -4,7 +4,6 @@ import React, {
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
@@ -49,11 +48,9 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
@@ -80,36 +77,9 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
const refreshRequestIds = useRef({
bots: 0,
pipelines: 0,
knowledgeBases: 0,
plugins: 0,
mcpServers: 0,
skills: 0,
});
const quotaResourceLoaded = useRef({
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
});
const setQuotaResourceLoaded = useCallback(
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
quotaResourceLoaded.current[resource] = loaded;
setQuotaDataLoaded(
Object.values(quotaResourceLoaded.current).every(Boolean),
);
},
[],
);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
@@ -126,11 +96,8 @@ export function SidebarDataProvider({
}, []);
const refreshBots = useCallback(async () => {
const requestId = ++refreshRequestIds.current.bots;
try {
const resp = await httpClient.getBots();
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', true);
setBots(
resp.bots.map((bot) => ({
id: bot.uuid || '',
@@ -142,18 +109,13 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', false);
console.error('Failed to fetch bots for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshPipelines = useCallback(async () => {
const requestId = ++refreshRequestIds.current.pipelines;
try {
const resp = await httpClient.getPipelines();
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', true);
setPipelines(
resp.pipelines.map((p) => ({
id: p.uuid || '',
@@ -164,18 +126,13 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', false);
console.error('Failed to fetch pipelines for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshKnowledgeBases = useCallback(async () => {
const requestId = ++refreshRequestIds.current.knowledgeBases;
try {
const resp = await httpClient.getKnowledgeBases();
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', true);
setKnowledgeBases(
resp.bases.map((kb) => ({
id: kb.uuid || '',
@@ -186,14 +143,11 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', false);
console.error('Failed to fetch knowledge bases for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshPlugins = useCallback(async () => {
const requestId = ++refreshRequestIds.current.plugins;
try {
const [pluginsResp, marketplaceResp] = await Promise.all([
httpClient.getPlugins(),
@@ -201,9 +155,6 @@ export function SidebarDataProvider({
.getMarketplacePlugins(1, 100)
.catch(() => ({ plugins: [] })),
]);
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', true);
setPluginCount(pluginsResp.plugins?.length ?? 0);
// Build marketplace version lookup: "author/name" -> latest_version
const marketplaceVersions = new Map<string, string>();
@@ -290,18 +241,13 @@ export function SidebarDataProvider({
}
setPluginPages(pages);
} catch (error) {
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', false);
console.error('Failed to fetch plugins for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshMCPServers = useCallback(async () => {
const requestId = ++refreshRequestIds.current.mcpServers;
try {
const resp = await httpClient.getMCPServers();
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', true);
setMCPServers(
resp.servers.map((server) => ({
id: server.name, // Keep __ for API calls
@@ -311,18 +257,13 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', false);
console.error('Failed to fetch MCP servers for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshSkills = useCallback(async () => {
const requestId = ++refreshRequestIds.current.skills;
try {
const resp = await httpClient.getSkills();
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', true);
setSkills(
resp.skills.map((skill) => ({
id: skill.name,
@@ -332,22 +273,11 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', false);
console.error('Failed to fetch skills for sidebar:', error);
}
}, [setQuotaResourceLoaded]);
}, []);
const refreshAll = useCallback(async () => {
quotaResourceLoaded.current = {
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
};
setQuotaDataLoaded(false);
await Promise.all([
refreshBots(),
refreshPipelines(),
@@ -377,11 +307,9 @@ export function SidebarDataProvider({
pipelines,
knowledgeBases,
plugins,
pluginCount,
mcpServers,
skills,
pluginPages,
quotaDataLoaded,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
@@ -218,22 +218,20 @@ export default function ProviderCard({
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
{isWorkspaceOwner && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
@@ -133,14 +133,12 @@ export default function SettingsDialog({
const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
}
if (item.id === 'storageAnalysis') {
return canViewStorageAnalysis;
return canViewAudit;
}
return true;
});
@@ -148,17 +146,11 @@ export default function SettingsDialog({
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
(section === 'storageAnalysis' && !canViewAudit);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [
canManageApiKeys,
canViewStorageAnalysis,
open,
section,
onSectionChange,
]);
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -264,7 +256,7 @@ export default function SettingsDialog({
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && canViewStorageAnalysis && (
{section === 'storageAnalysis' && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
@@ -1,47 +0,0 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
export function WorkspaceQuotaTooltip({
quota,
resource,
children,
side = 'top',
}: {
quota: WorkspaceQuotaItem;
resource: string;
children: ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
const { t } = useTranslation();
if (!quota.disabled) return children;
const message = quota.loading
? t('limitation.quotaLoadingTooltip')
: t('limitation.createDisabledTooltip', {
resource,
max: quota.max,
});
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-disabled="true"
aria-label={message}
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</span>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-72 text-left">
{message}
</TooltipContent>
</Tooltip>
);
}
@@ -1,82 +0,0 @@
import { systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface WorkspaceQuotaItem {
count: number;
max: number;
reached: boolean;
loading: boolean;
disabled: boolean;
}
export interface WorkspaceQuotaStatus {
bots: WorkspaceQuotaItem;
pipelines: WorkspaceQuotaItem;
knowledgeBases: WorkspaceQuotaItem;
extensions: WorkspaceQuotaItem;
botsReached: boolean;
pipelinesReached: boolean;
knowledgeBasesReached: boolean;
extensionsReached: boolean;
}
function quotaItem(
count: number,
max: number | undefined,
loaded: boolean,
): WorkspaceQuotaItem {
const normalizedMax = typeof max === 'number' ? max : -1;
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
return {
count,
max: normalizedMax,
reached,
loading: !loaded,
disabled: !loaded || reached,
};
}
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
const {
bots,
pipelines,
knowledgeBases,
pluginCount,
mcpServers,
skills,
quotaDataLoaded,
} = useSidebarData();
const limitation = systemInfo.limitation;
const botQuota = quotaItem(
bots.length,
limitation?.max_bots,
quotaDataLoaded,
);
const pipelineQuota = quotaItem(
pipelines.length,
limitation?.max_pipelines,
quotaDataLoaded,
);
const knowledgeBaseQuota = quotaItem(
knowledgeBases.length,
limitation?.max_knowledge_bases,
quotaDataLoaded,
);
const extensionQuota = quotaItem(
pluginCount + mcpServers.length + skills.length,
limitation?.max_extensions,
quotaDataLoaded,
);
return {
bots: botQuota,
pipelines: pipelineQuota,
knowledgeBases: knowledgeBaseQuota,
extensions: extensionQuota,
botsReached: botQuota.disabled,
pipelinesReached: pipelineQuota.disabled,
knowledgeBasesReached: knowledgeBaseQuota.disabled,
extensionsReached: extensionQuota.disabled,
};
}
@@ -79,6 +79,7 @@ export default function WorkspaceSettingsPanel({
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
@@ -313,20 +314,18 @@ export default function WorkspaceSettingsPanel({
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemContent>
<ItemTitle>
{member.display_name}
{member.email}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="break-all">{member.email}</span>
<span aria-hidden="true">·</span>
<span>{t(`workspace.roles.${member.role}`)}</span>
<ItemDescription>
{t(`workspace.roles.${member.role}`)}
</ItemDescription>
</ItemContent>
<ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
<ItemActions>
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
@@ -343,6 +342,11 @@ export default function WorkspaceSettingsPanel({
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent>
</Select>
)}
+17 -28
View File
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/providers/theme-provider';
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
/**
* Plugin page that renders a plugin-provided HTML page in an iframe.
@@ -81,15 +80,11 @@ function PluginPageIframe({
pageId: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
const [loading, setLoading] = useState(true);
const { resolvedTheme } = useTheme();
const { t, i18n } = useTranslation();
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
author,
pluginName,
pagePath,
);
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
const { i18n } = useTranslation();
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
@@ -175,29 +170,23 @@ function PluginPageIframe({
return (
<div className="flex flex-col h-full w-full">
{assetError ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('plugins.loadFailed')}
</div>
) : loading || !assetUrl ? (
{loading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
) : null}
{!assetError && assetUrl && (
<iframe
ref={iframeRef}
src={assetUrl}
className="flex-1 w-full border-0 rounded-md"
style={{ display: loading ? 'none' : 'block' }}
onLoad={() => {
setLoadedAssetUrl(assetUrl);
sendContext();
}}
sandbox="allow-scripts allow-forms"
title={`${author}/${pluginName} - ${pagePath}`}
/>
)}
<iframe
ref={iframeRef}
src={assetUrl}
className="flex-1 w-full border-0 rounded-md"
style={{ display: loading ? 'none' : 'block' }}
onLoad={() => {
setLoading(false);
sendContext();
}}
sandbox="allow-scripts allow-forms"
title={`${author}/${pluginName} - ${pagePath}`}
/>
</div>
);
}
@@ -7,8 +7,6 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
@@ -18,8 +16,6 @@ interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
}
function formatFileSize(bytes: number): string {
@@ -34,8 +30,6 @@ export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
quota,
quotaResource = '',
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
@@ -69,7 +63,6 @@ export default function PluginLocalPreviewPanel({
}, [loadPreview]);
async function handleInstall() {
if (quota?.disabled) return;
setInstalling(true);
setErrorMessage(null);
try {
@@ -197,27 +190,13 @@ export default function PluginLocalPreviewPanel({
{t('common.cancel')}
</Button>
)}
{quota ? (
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
<Button
type="button"
onClick={handleInstall}
disabled={quota.disabled || !preview || previewing || installing}
>
{installing
? t('plugins.installing')
: t('plugins.confirmInstall')}
</Button>
</WorkspaceQuotaTooltip>
) : (
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
</div>
</div>
);
@@ -80,13 +80,9 @@ function loadMarketFilters(): MarketFilters {
function MarketPageContent({
installPlugin,
headerActions,
installDisabled,
installDisabledTooltip,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
@@ -851,8 +847,6 @@ function MarketPageContent({
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
)}
@@ -882,8 +876,6 @@ function MarketPageContent({
cardVO={plugin}
onInstall={handleInstallPlugin}
tagNames={tagNames}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
</div>
@@ -923,13 +915,9 @@ function MarketPageContent({
export default function MarketPage({
installPlugin,
headerActions,
installDisabled,
installDisabledTooltip,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
return (
<Suspense
@@ -944,8 +932,6 @@ export default function MarketPage({
<MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
</Suspense>
);
@@ -54,15 +54,11 @@ function RecommendationListRow({
list,
tagNames,
onInstall,
installDisabled,
installDisabledTooltip,
isLast,
}: {
list: RecommendationList;
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean;
}) {
const { t } = useTranslation();
@@ -267,8 +263,6 @@ function RecommendationListRow({
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
</div>
@@ -283,14 +277,10 @@ export function RecommendationLists({
lists,
tagNames,
onInstall,
installDisabled,
installDisabledTooltip,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
if (!lists || lists.length === 0) return null;
@@ -302,8 +292,6 @@ export function RecommendationLists({
list={list}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
isLast={index === lists.length - 1}
/>
))}
@@ -23,14 +23,10 @@ export default function PluginMarketCardComponent({
cardVO,
onInstall,
tagNames = {},
installDisabled = false,
installDisabledTooltip,
}: {
cardVO: PluginMarketCardVO;
onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
const { t } = useTranslation();
const bottomRef = useRef<HTMLDivElement>(null);
@@ -131,7 +127,6 @@ export default function PluginMarketCardComponent({
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
const handleInstallClick = () => {
if (installDisabled) return;
onInstall?.(cardVO);
};
@@ -158,17 +153,12 @@ export default function PluginMarketCardComponent({
}
};
const cardContent = (
return (
<div
role={installDisabled ? 'group' : 'button'}
role="button"
tabIndex={0}
aria-disabled={installDisabled}
aria-label={t('market.installCard', { name: cardVO.label })}
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
installDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
}`}
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
onClick={handleInstallClick}
onKeyDown={(event) => {
if (
@@ -392,17 +382,4 @@ export default function PluginMarketCardComponent({
</div>
</div>
);
if (!installDisabled || !installDisabledTooltip) return cardContent;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 text-left">
{installDisabledTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -7,8 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
interface PreviewSkill extends Skill {
source_path?: string;
@@ -18,8 +16,6 @@ interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
}
function formatFileSize(bytes: number): string {
@@ -49,8 +45,6 @@ export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
quota,
quotaResource = '',
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
@@ -123,7 +117,6 @@ export default function SkillZipPreviewPanel({
}
async function handleInstall() {
if (quota?.disabled) return;
if (selectedPaths.length === 0) return;
setInstalling(true);
@@ -256,56 +249,28 @@ export default function SkillZipPreviewPanel({
{t('common.cancel')}
</Button>
)}
{quota ? (
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
<Button
type="button"
onClick={handleInstall}
disabled={
quota.disabled ||
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</WorkspaceQuotaTooltip>
) : (
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</div>
</div>
);
-1
View File
@@ -328,7 +328,6 @@ export interface SystemLimitation {
max_bots: number;
max_pipelines: number;
max_extensions: number;
max_knowledge_bases?: number;
/** When non-empty, every pipeline is forced to this Box sandbox-scope
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
-1
View File
@@ -19,7 +19,6 @@ export interface WorkspaceMembership {
uuid: string;
workspace_uuid: string;
account_uuid: string;
display_name: string;
email: string;
role: WorkspaceRole;
status: 'active' | 'disabled' | 'removed';
-1
View File
@@ -1179,7 +1179,6 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{
initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
}> {
+1 -19
View File
@@ -93,10 +93,6 @@ export default function AcceptInvitationPage() {
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
useState(false);
const [
authenticatedInvitationAcceptanceEnabled,
setAuthenticatedInvitationAcceptanceEnabled,
] = useState(false);
useEffect(() => {
const handleHashChange = () => setInvitationHash(window.location.hash);
@@ -117,9 +113,6 @@ export default function AcceptInvitationPage() {
.getAccountInfo()
.then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true,
);
})
.catch(() => setPasswordRegistrationEnabled(false));
if (!invitationToken) {
@@ -311,18 +304,7 @@ export default function AcceptInvitationPage() {
</div>
)}
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={() => void finishAcceptance()}
>
{status === 'submitting' ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{t('workspace.acceptInvitation')}
</Button>
) : hasLoginToken ? (
{hasLoginToken ? (
<div className="space-y-3">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
{t('workspace.authenticatedInvitationNotice')}
+1 -1
View File
@@ -119,7 +119,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs',
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
+20 -52
View File
@@ -1,63 +1,41 @@
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http';
type AuthenticatedResourceState = {
key: string;
url: string;
error: boolean;
};
const EMPTY_RESOURCE: AuthenticatedResourceState = {
key: '',
url: '',
error: false,
};
export function useAuthenticatedPluginIcon(
author: string,
name: string,
enabled = true,
): { url: string; error: boolean } {
const [resource, setResource] =
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
const [url, setURL] = useState('');
const [error, setError] = useState(false);
useEffect(() => {
if (!enabled) {
setResource({ key: resourceKey, url: '', error: false });
setURL('');
setError(false);
return;
}
let active = true;
let objectURL = '';
setResource({ key: resourceKey, url: '', error: false });
setURL('');
setError(false);
httpClient
.getAuthenticatedPluginIconURL(author, name)
.then((nextURL) => {
objectURL = nextURL;
if (active) {
setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
})
.catch(() => {
if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
if (active) setError(true);
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, enabled, name, resourceKey]);
}, [author, enabled, name]);
return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
return { url, error };
}
export function useAuthenticatedPluginAsset(
@@ -65,39 +43,29 @@ export function useAuthenticatedPluginAsset(
name: string,
filepath: string,
): { url: string; error: boolean } {
const [resource, setResource] =
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
const [url, setURL] = useState('');
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
let objectURL = '';
setResource({ key: resourceKey, url: '', error: false });
setURL('');
setError(false);
httpClient
.getAuthenticatedPluginAssetURL(author, name, filepath)
.then((nextURL) => {
objectURL = nextURL;
if (active) {
setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
})
.catch(() => {
if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
if (active) setError(true);
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, name, filepath, resourceKey]);
}, [author, name, filepath]);
return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
return { url, error };
}
-6
View File
@@ -1678,12 +1678,6 @@ const enUS = {
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
maxExtensionsReached:
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
quotaLoadingTooltip:
'Workspace usage is still loading. Please wait before creating a resource.',
quotaCheckFailed:
'Unable to verify the current workspace quota. Please try again.',
createDisabledTooltip:
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
},
skills: {
title: 'Skills',
-6
View File
@@ -1634,12 +1634,6 @@ const esES = {
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
maxExtensionsReached:
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
quotaLoadingTooltip:
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
quotaCheckFailed:
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
createDisabledTooltip:
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
},
wizard: {
sidebarDescription: 'Crea un Bot con pasos guiados',
-6
View File
@@ -1685,12 +1685,6 @@ const jaJP = {
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
maxExtensionsReached:
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
quotaLoadingTooltip:
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
quotaCheckFailed:
'現在のワークスペース上限を確認できません。もう一度お試しください。',
createDisabledTooltip:
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
},
wizard: {
sidebarDescription: 'ガイド付きステップでボットを作成',
-6
View File
@@ -1609,12 +1609,6 @@ const ruRU = {
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
maxExtensionsReached:
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
quotaLoadingTooltip:
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
quotaCheckFailed:
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
createDisabledTooltip:
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
},
wizard: {
sidebarDescription: 'Создать бота с пошаговым руководством',
-6
View File
@@ -1576,12 +1576,6 @@ const thTH = {
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
maxExtensionsReached:
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
quotaLoadingTooltip:
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
quotaCheckFailed:
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
createDisabledTooltip:
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
},
wizard: {
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
-6
View File
@@ -1602,12 +1602,6 @@ const viVN = {
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
maxExtensionsReached:
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
quotaLoadingTooltip:
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
quotaCheckFailed:
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
createDisabledTooltip:
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
},
wizard: {
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
-4
View File
@@ -1606,10 +1606,6 @@ const zhHans = {
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
maxExtensionsReached:
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
createDisabledTooltip:
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
},
skills: {
title: '技能',
-4
View File
@@ -1531,10 +1531,6 @@ const zhHant = {
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
maxExtensionsReached:
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
createDisabledTooltip:
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
},
wizard: {
sidebarDescription: '透過引導步驟建立機器人',
@@ -1,63 +0,0 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
function wrapped(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
test('Cloud never exposes or requests storage analysis', async ({ page }) => {
const workspace = makeWorkspaceEntry(
'workspace-cloud',
'Cloud Workspace',
'cloud_projection',
);
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.route(
/\/api\/v1\/workspaces\/workspace-cloud\/(members|invitations)$/,
async (route) => {
const collection = route.request().url().endsWith('/members')
? 'members'
: 'invitations';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({ [collection]: [] }),
});
},
);
let storageAnalysisRequests = 0;
await page.route('**/api/v1/system/storage-analysis', async (route) => {
storageAnalysisRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({}),
});
});
await page.goto('/home/bots');
await page.getByRole('button', { name: /admin@example\.com/i }).click();
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
0,
);
await page.goto('/home/bots?action=showStorageAnalysis');
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Workspace' })).toBeVisible();
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
0,
);
expect(storageAnalysisRequests).toBe(0);
});
+1 -2
View File
@@ -81,7 +81,6 @@ export interface WorkspaceEntryMock {
uuid: string;
workspace_uuid: string;
account_uuid: string;
display_name: string;
email: string;
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
status: 'active';
@@ -156,7 +155,6 @@ export function makeWorkspaceEntry(
uuid: `membership-${uuid}`,
workspace_uuid: uuid,
account_uuid: 'account-playwright',
display_name: 'Playwright Admin',
email: 'admin@example.com',
role: 'owner',
status: 'active',
@@ -171,6 +169,7 @@ export function makeWorkspaceEntry(
'member.remove',
'member.update_role',
'member.view',
'owner.transfer',
'provider_secret.manage',
'resource.manage',
'resource.view',
-161
View File
@@ -165,164 +165,3 @@ test('an authenticated OSS invitation requires logout before registration', asyn
invitation: 'logout-invitation',
});
});
test('an authenticated Cloud Account can accept its invitation directly', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: {
token: 'invited-account-token',
userEmail: 'invited@example.com',
},
});
await page.route('**/api/v1/user/account-info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: true,
password_login_enabled: false,
space_login_enabled: true,
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/inspect', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
invitation: {
uuid: 'cloud-invitation',
workspace_uuid: 'workspace-playwright',
normalized_email: 'invited@example.com',
role: 'viewer',
status: 'pending',
},
workspace: {
uuid: 'workspace-playwright',
name: 'Playwright Workspace',
},
},
msg: 'ok',
}),
});
});
let acceptanceAuthorization = '';
await page.route('**/api/v1/invitations/accept', async (route) => {
acceptanceAuthorization = route.request().headers().authorization ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'accepted-cloud-account-token',
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/invitations/accept#token=cloud-invitation');
await expect(
page.getByRole('button', { name: 'Accept Invitation' }),
).toBeVisible();
await page.getByRole('button', { name: 'Accept Invitation' }).click();
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
});
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: false,
storage: {
token: 'stale-other-account-token',
userEmail: 'other@example.com',
},
});
await page.addInitScript(() => {
sessionStorage.setItem(
'langbot_pending_invitation_token',
'matching-invitation',
);
});
await page.route('**/api/v1/user/space/callback', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'fresh-invited-account-token',
user: 'invited@example.com',
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/user/info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
account_uuid: 'invited-account',
user: 'invited@example.com',
account_type: 'space',
has_password: false,
},
msg: 'ok',
}),
});
});
let acceptanceAuthorization = '';
await page.route('**/api/v1/invitations/accept', async (route) => {
acceptanceAuthorization = route.request().headers().authorization ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
token: 'accepted-invited-account-token',
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
timeout: 5_000,
});
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
expect(
await page.evaluate(() => ({
token: localStorage.getItem('token'),
userEmail: localStorage.getItem('userEmail'),
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
})),
).toEqual({
token: 'accepted-invited-account-token',
userEmail: 'invited@example.com',
invitation: null,
});
});
-88
View File
@@ -1,88 +0,0 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
function wrapped(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
test('loads a Cloud plugin page through the authenticated asset route', async ({
page,
}) => {
const workspace = makeWorkspaceEntry(
'workspace-cloud',
'Cloud Workspace',
'cloud_projection',
);
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.route('**/api/v1/plugins', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({
plugins: [
{
install_source: 'marketplace',
install_info: {},
debug: false,
manifest: {
manifest: {
metadata: {
author: 'langbot-team',
name: 'LangRAG',
version: '0.1.9',
label: { en_US: 'LangRAG', zh_Hans: 'LangRAG' },
},
spec: {
pages: [
{
id: 'observability',
path: 'components/pages/observability.html',
label: { en_US: 'Observability', zh_Hans: '观测面板' },
},
],
},
},
},
},
],
}),
});
});
let authenticatedAssetRequests = 0;
await page.route(
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
async (route) => {
authenticatedAssetRequests += 1;
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
});
},
);
await page.goto(
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
);
await expect(
page
.frameLocator('iframe')
.getByRole('heading', { name: 'LangRAG Observability' }),
).toBeVisible();
expect(authenticatedAssetRequests).toBeGreaterThan(0);
await expect(page.getByText('Loading...')).toHaveCount(0);
});
-149
View File
@@ -1,149 +0,0 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
function wrapped(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
async function fulfill(
route: Parameters<Parameters<import('@playwright/test').Page['route']>[1]>[0],
data: unknown,
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped(data),
});
}
test('quota-reached create actions are disabled and explain the current limit', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', (route) =>
fulfill(route, {
debug: false,
version: 'quota-e2e',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: 2,
max_pipelines: 3,
max_extensions: 3,
max_knowledge_bases: 2,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
}),
);
await page.route('**/api/v1/platform/bots**', (route) =>
fulfill(route, {
bots: Array.from({ length: 2 }, (_, index) => ({
uuid: `bot-${index}`,
name: `Bot ${index + 1}`,
description: '',
adapter: 'aiocqhttp',
enable: true,
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/pipelines**', (route) =>
fulfill(route, {
pipelines: Array.from({ length: 3 }, (_, index) => ({
uuid: `pipeline-${index}`,
name: `Pipeline ${index + 1}`,
description: '',
emoji: '⚙️',
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/knowledge/bases**', (route) =>
fulfill(route, {
bases: Array.from({ length: 2 }, (_, index) => ({
uuid: `kb-${index}`,
name: `Knowledge ${index + 1}`,
description: '',
emoji: '📚',
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/plugins**', (route) =>
fulfill(route, { plugins: [] }),
);
await page.route('**/api/v1/mcp/servers**', (route) =>
fulfill(route, {
servers: Array.from({ length: 3 }, (_, index) => ({
name: `mcp-${index}`,
mode: 'http',
enable: true,
runtime_info: { status: 'connected' },
})),
}),
);
await page.route('**/api/v1/skills**', (route) =>
fulfill(route, { skills: [] }),
);
await page.goto('/home/bots');
const botCreate = page.getByRole('button', {
name: 'Create Bots',
exact: true,
});
const pipelineCreate = page.getByRole('button', {
name: 'Create Pipelines',
exact: true,
});
const knowledgeCreate = page.getByRole('button', {
name: 'Create Knowledge',
exact: true,
});
const addExtension = page.getByRole('button', {
name: 'Add Extension',
exact: true,
});
await expect(botCreate).toBeDisabled();
await expect(pipelineCreate).toBeDisabled();
await expect(knowledgeCreate).toBeDisabled();
await expect(addExtension).toBeEnabled();
const botQuotaTrigger = botCreate.locator('..');
await botQuotaTrigger.hover();
await expect(
page.getByText(
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
await botQuotaTrigger.focus();
await expect(botQuotaTrigger).toBeFocused();
await expect(
page.getByText(
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
await addExtension.click();
await expect(page).toHaveURL(/\/home\/add-extension$/);
const manualAdd = page.getByRole('button', { name: 'Manual Add' });
await expect(manualAdd).toBeDisabled();
await manualAdd.locator('..').hover();
await expect(
page.getByText(
'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
});
@@ -1,102 +0,0 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
const members = [
{
uuid: 'membership-owner',
workspace_uuid: 'workspace-email-test',
account_uuid: 'account-playwright',
display_name: 'RockChinQ',
email: 'rock@example.com',
role: 'owner',
status: 'active',
joined_at: '2026-08-01T00:00:00Z',
created_at: '2026-08-01T00:00:00Z',
},
{
uuid: 'membership-admin',
workspace_uuid: 'workspace-email-test',
account_uuid: 'account-admin',
display_name: 'Junyan Qin',
email: 'a.very.long.workspace.member.email.address@example-company.test',
role: 'admin',
status: 'active',
joined_at: '2026-08-02T00:00:00Z',
created_at: '2026-08-02T00:00:00Z',
},
];
test.use({ viewport: { width: 390, height: 844 } });
test('workspace member list displays each member email without overlapping controls', async ({
page,
}, testInfo) => {
const workspace = makeWorkspaceEntry(
'workspace-email-test',
'Email Test Workspace',
'cloud_projection',
);
workspace.membership.display_name = 'RockChinQ';
workspace.membership.email = 'rock@example.com';
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.route(
'**/api/v1/workspaces/workspace-email-test/members',
(route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'ok', data: { members } }),
}),
);
await page.route(
'**/api/v1/workspaces/workspace-email-test/invitations',
(route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'ok',
data: { invitations: [] },
}),
}),
);
await page.goto('/home?action=showWorkspaceSettings');
await expect(page.getByText('RockChinQ')).toBeVisible();
await expect(page.getByText('rock@example.com')).toBeVisible();
await expect(page.getByText('Junyan Qin')).toBeVisible();
const longEmail = page.getByText(
'a.very.long.workspace.member.email.address@example-company.test',
);
await expect(longEmail).toBeVisible();
await longEmail.scrollIntoViewIfNeeded();
const roleSelect = page.getByRole('combobox').last();
const [emailBox, selectBox] = await Promise.all([
longEmail.boundingBox(),
roleSelect.boundingBox(),
]);
expect(emailBox).not.toBeNull();
expect(selectBox).not.toBeNull();
const overlaps =
emailBox!.x < selectBox!.x + selectBox!.width &&
emailBox!.x + emailBox!.width > selectBox!.x &&
emailBox!.y < selectBox!.y + selectBox!.height &&
emailBox!.y + emailBox!.height > selectBox!.y;
expect(overlaps).toBe(false);
expect(selectBox!.y).toBeGreaterThanOrEqual(emailBox!.y + emailBox!.height);
await roleSelect.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath('workspace-member-emails-mobile.png'),
fullPage: true,
});
});
@@ -46,14 +46,4 @@ test('provider card represents owner and member owner-bound states explicitly',
assert.match(source, /ownerSpaceBound/);
assert.match(source, /models\.ownerMustBindSpace/);
assert.match(source, /models\.usesOwnerSpaceBilling/);
assert.match(source, /isWorkspaceOwner && \(\s*<Button/);
});
test('workspace member controls never offer ownership transfer', () => {
const source = read(
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
);
assert.doesNotMatch(source, /canTransferOwner/);
assert.doesNotMatch(source, /workspace\.transferOwnership/);
assert.doesNotMatch(source, /<SelectItem value="owner">/);
});
@@ -1,100 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(currentDirectory, '../..');
function readSource(relativePath) {
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
}
const homeSidebarSource = readSource(
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
);
const botFormSource = readSource(
'src/app/home/bots/components/bot-form/BotForm.tsx',
);
const kbFormSource = readSource(
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
);
const settingsDialogSource = readSource(
'src/app/home/components/settings-dialog/SettingsDialog.tsx',
);
const pluginPageSource = readSource('src/app/home/plugin-pages/page.tsx');
const authenticatedPluginResourceSource = readSource(
'src/hooks/useAuthenticatedPluginResource.ts',
);
test('hides the entire workspace switcher slot for a singleton local workspace', () => {
assert.match(homeSidebarSource, /useWorkspaceBootstrap/);
assert.match(
homeSidebarSource,
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
);
assert.match(
homeSidebarSource,
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>\s*<WorkspaceSwitcher/,
);
});
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
assert.match(
botFormSource,
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
);
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
});
test('does not expose storage analysis in Cloud settings or via a deep link', () => {
assert.match(
homeSidebarSource,
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?openSettings\('storageAnalysis'\)/,
);
assert.match(
settingsDialogSource,
/const canViewStorageAnalysis\s*=\s*currentWorkspace\?\.workspace\.source\s*!==\s*'cloud_projection'\s*&&\s*canViewAudit/,
);
assert.match(
settingsDialogSource,
/item\.id === 'storageAnalysis'[\s\S]*?return canViewStorageAnalysis/,
);
assert.match(
settingsDialogSource,
/section === 'storageAnalysis' && !canViewStorageAnalysis/,
);
assert.match(
settingsDialogSource,
/section === 'storageAnalysis' &&\s*canViewStorageAnalysis &&\s*\(\s*<StorageAnalysisPanel/,
);
});
test('loads plugin pages through the authenticated Workspace-scoped asset route', () => {
assert.match(pluginPageSource, /useAuthenticatedPluginAsset/);
assert.match(
pluginPageSource,
/useAuthenticatedPluginAsset\(\s*author,\s*pluginName,\s*pagePath,?\s*\)/,
);
assert.match(pluginPageSource, /src=\{assetUrl\}/);
assert.doesNotMatch(pluginPageSource, /getPluginAssetURL\(/);
assert.match(pluginPageSource, /plugins\.loadFailed/);
assert.match(pluginPageSource, /loadedAssetUrl !== assetUrl/);
});
test('revokes and reloads authenticated plugin resources when the Workspace changes', () => {
assert.match(authenticatedPluginResourceSource, /useCurrentWorkspace/);
assert.match(
authenticatedPluginResourceSource,
/const workspaceUuid = currentWorkspace\?\.workspace\.uuid;/,
);
assert.match(
authenticatedPluginResourceSource,
/\[author, name, filepath, resourceKey\]/,
);
assert.match(
authenticatedPluginResourceSource,
/resource\.key === resourceKey \? resource\.url : ''/,
);
});
@@ -1,122 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const quotaPath = path.join(
root,
'src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts',
);
const sidebarPath = path.join(
root,
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
);
const tooltipPath = path.join(
root,
'src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx',
);
const baseTooltipPath = path.join(root, 'src/components/ui/tooltip.tsx');
const addExtensionPath = path.join(root, 'src/app/home/add-extension/page.tsx');
const marketPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
);
const marketCardPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
);
const recommendationPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/RecommendationLists.tsx',
);
const zhPath = path.join(root, 'src/i18n/locales/zh-Hans.ts');
test('workspace quota hook exposes reached states for every creatable resource', () => {
assert.equal(
fs.existsSync(quotaPath),
true,
'workspace quota hook is missing',
);
const source = fs.readFileSync(quotaPath, 'utf8');
for (const token of [
'botsReached',
'pipelinesReached',
'knowledgeBasesReached',
'extensionsReached',
'max_bots',
'max_pipelines',
'max_knowledge_bases',
'max_extensions',
]) {
assert.match(source, new RegExp(token));
}
});
test('sidebar quota-disables create controls and renders a tooltip', () => {
const source = fs.readFileSync(sidebarPath, 'utf8');
const tooltip = fs.readFileSync(tooltipPath, 'utf8');
const baseTooltip = fs.readFileSync(baseTooltipPath, 'utf8');
assert.match(source, /useWorkspaceQuotaStatus/);
assert.match(source, /quota\.disabled/);
assert.match(source, /disabled=\{quota\.disabled\}/);
assert.match(source, /WorkspaceQuotaTooltip/);
assert.match(tooltip, /TooltipContent/);
assert.match(tooltip, /limitation\.createDisabledTooltip/);
assert.match(tooltip, /limitation\.quotaLoadingTooltip/);
assert.match(tooltip, /tabIndex=\{0\}/);
assert.match(tooltip, /max-w-72 text-left/);
assert.doesNotMatch(tooltip, /text-center/);
assert.doesNotMatch(baseTooltip, /text-balance/);
assert.match(source, /config\.id === 'add-extension'/);
assert.doesNotMatch(
source,
/config\.id === 'add-extension'\s*\?\s*quotaStatus\.extensions/,
);
});
test('add-extension page disables all install entry points at the quota', () => {
const page = fs.readFileSync(addExtensionPath, 'utf8');
const market = fs.readFileSync(marketPath, 'utf8');
const card = fs.readFileSync(marketCardPath, 'utf8');
const recommendations = fs.readFileSync(recommendationPath, 'utf8');
assert.match(page, /extensionsReached/);
assert.match(page, /installDisabled=\{extensionsReached\}/);
assert.match(page, /disabled=\{extensionsReached/);
assert.match(page, /limitation\.createDisabledTooltip/);
assert.match(market, /installDisabled/);
assert.match(card, /installDisabled/);
assert.match(card, /disabled=\{installDisabled\}/);
assert.match(card, /TooltipContent/);
assert.match(card, /max-w-72 text-left/);
assert.doesNotMatch(card, /max-w-72 text-center/);
assert.match(recommendations, /installDisabled=\{installDisabled\}/);
assert.match(
recommendations,
/installDisabledTooltip=\{installDisabledTooltip\}/,
);
assert.match(page, /quota=\{extensionQuota\}/);
});
test('extension confirmation checks fail closed and enter an in-flight state first', () => {
const page = fs.readFileSync(addExtensionPath, 'utf8');
assert.match(page, /limitation\.quotaCheckFailed/);
assert.doesNotMatch(page, /If we can't check, let backend handle it/);
assert.match(
page,
/setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
);
assert.match(
page,
/setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
);
});
test('quota tooltip copy is localized in Simplified Chinese', () => {
const source = fs.readFileSync(zhPath, 'utf8');
assert.match(source, /createDisabledTooltip/);
assert.match(source, /已达到.*上限/);
assert.match(source, /删除.*后再/);
});
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url),
'utf8',
);
test('direct launch assertion is fragment-only and removed before exchange', () => {
assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/);
const readIndex = source.indexOf("fragmentParams.get('launch_assertion')");
const clearIndex = source.indexOf('window.history.replaceState');
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
assert.ok(
clearIndex > readIndex,
'URL fragment is not cleared after copying the assertion',
);
assert.ok(
exchangeIndex > clearIndex,
'assertion exchange starts before the fragment is cleared',
);
});
@@ -1,24 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const panelSource = await readFile(
new URL(
'../../src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
import.meta.url,
),
'utf8',
);
const entitySource = await readFile(
new URL('../../src/app/infra/entities/workspace.ts', import.meta.url),
'utf8',
);
test('workspace member rows show display name, email, and role', () => {
assert.match(entitySource, /display_name:\s*string/);
assert.match(panelSource, /\{member\.display_name\}/);
assert.match(
panelSource,
/<ItemDescription[^>]*>[\s\S]*?\{member\.email\}[\s\S]*?workspace\.roles\.\$\{member\.role\}[\s\S]*?<\/ItemDescription>/,
);
});
@@ -50,22 +50,15 @@ test('places WorkspaceSwitcher between the sidebar header and Home navigation',
);
});
test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => {
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
assert.match(
workspaceSwitcherSource,
/if \(!currentWorkspace\) return null;/,
);
assert.match(
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
assert.doesNotMatch(
homeSidebarSource,
/const workspaces = useWorkspaceBootstrap\(\);/,
);
assert.match(
homeSidebarSource,
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
);
assert.match(
homeSidebarSource,
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>[\s\S]*?<WorkspaceSwitcher/,
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
);
});