Compare commits

..

53 Commits

Author SHA1 Message Date
dadachann 7b03e3395f chore: merge master into feat/multi-tenants
- Resolved conflict in provider.py: merged rerank model support with tenant context
- Resolved conflict in telegram.py: kept async.to_thread optimization and added url parameter
- Resolved conflict in test_model_manager.py: kept both cloud runtime and rerank tests
2026-07-30 04:23:43 +00:00
dadachann d3c443a2c8 fix(cloud): handle unavailable skill capability 2026-07-29 18:55:44 +00:00
dadachann 4b2a628db6 fix(deps): pin green multi-tenant plugin SDK 2026-07-29 11:15:18 +00:00
Junyan Qin 610915b9c5 fix(runtime): bound tenant resource amplification 2026-07-29 18:27:44 +08:00
Junyan Qin e8d90c4259 fix(cloud): bound tenant maintenance and monitoring work 2026-07-29 16:02:44 +08:00
dadachann 2dfbe78271 fix(cloud): scope public login capability discovery 2026-07-29 05:58:30 +00:00
Junyan Qin c89e6f3bd2 fix(cloud): enforce instance capacity ceilings 2026-07-29 13:45:58 +08:00
Junyan Qin e52d6880f5 fix(cloud): eliminate periodic runtime CPU spikes 2026-07-29 12:47:53 +08:00
Junyan Qin aa342d9347 fix(cloud): bound runtime restart storms 2026-07-29 12:14:08 +08:00
Junyan Qin ae85ac2b16 feat(cloud): harden multi-tenant runtime resources 2026-07-29 11:32:26 +08:00
dadachann 32abbb636f fix(oss): resolve workspace owner in scoped session 2026-07-26 16:30:15 +08:00
dadachann f247a9d183 test(oss): cover invitation logout handoff 2026-07-26 16:12:03 +08:00
dadachann 624a197655 style: format OSS account service 2026-07-26 16:09:50 +08:00
dadachann 712f79ed77 feat(oss): enforce invitation account and owner billing flows 2026-07-26 16:04:33 +08:00
dadachann 602e10649b fix(cloud): recover box runtime without unscoped skill reload 2026-07-26 14:25:49 +08:00
dadachann d71bd571b1 style(web): format invitation flows 2026-07-26 14:01:20 +08:00
dadachann e90a1546de feat(cloud): complete secure invitation experience 2026-07-26 13:54:42 +08:00
dadachann ff068564ab test(cloud): require Space identity for invite registration 2026-07-26 12:32:02 +08:00
dadachann 94ff4fcd2d fix(cloud): preserve Core-owned collaboration state 2026-07-26 12:27:47 +08:00
dadachann 97b3e58884 fix(workspace): bind collaboration APIs to tenant UoW 2026-07-26 11:28:46 +08:00
dadachann 66a1ceac25 style: format collaboration changes 2026-07-26 10:54:21 +08:00
dadachann 8a445bfb22 feat(workspace): add in-product collaboration and direct Cloud launch 2026-07-26 10:40:36 +08:00
dadachann 276791e7af fix(ui): align workspace switcher with sidebar entries 2026-07-26 00:23:17 +08:00
dadachann baf7e86335 fix(ui): hide roles from workspace switcher 2026-07-26 00:07:44 +08:00
dadachann 741c20af07 fix(ui): widen and center workspace switcher 2026-07-25 23:44:49 +08:00
dadachann 5ac1ab3eac fix(plugin): keep runtime identity stable across restarts 2026-07-25 21:56:14 +08:00
dadachann f96116a050 fix(cloud): surface runtime and workspace plan status 2026-07-25 21:42:05 +08:00
dadachann 40abb03928 style(web): format workspace layout test 2026-07-25 21:04:23 +08:00
dadachann 59f68b8fb4 refactor(web): streamline workspace controls 2026-07-25 20:59:05 +08:00
dadachann 7c64cd9d51 feat(web): place workspace controls in sidebar 2026-07-25 16:26:13 +08:00
dadachann 9ea1a81048 test(web): cover Workspace dropdown menu 2026-07-25 14:11:28 +08:00
dadachann d3f08a90b1 feat(cloud): complete Workspace settings navigation 2026-07-25 13:51:37 +08:00
dadachann 64e772e32d fix(cloud): reuse authenticated account for user info 2026-07-25 02:41:31 +08:00
dadachann 84440df47f fix(cloud): preserve authenticated account context 2026-07-25 02:05:17 +08:00
dadachann c860159446 test(cloud): preserve minimal model manager fixtures 2026-07-25 00:45:42 +08:00
dadachann f977629a90 fix(cloud): skip legacy model sync during startup 2026-07-25 00:26:56 +08:00
dadachann ff13d52602 chore: update multi-tenant SDK pin 2026-07-24 23:31:08 +08:00
Junyan Qin 5beab49577 docs(cloud): update control plane verification 2026-07-24 22:58:56 +08:00
Junyan Qin e8a09b7537 fix(build): install git for pinned SDK 2026-07-24 19:29:14 +08:00
Junyan Qin 98f45aa88e feat(tenancy): connect cloud workspace control plane 2026-07-24 19:11:33 +08:00
Junyan Qin d7cdd206c2 docs(tenancy): record final isolation verification 2026-07-24 16:22:45 +08:00
Junyan Qin ac72563664 fix(tenancy): close isolation and permission gaps 2026-07-24 16:22:45 +08:00
Junyan Qin 64dc887b20 docs(tenancy): record final isolation verification 2026-07-24 16:22:45 +08:00
Junyan Qin 627eb6b8ef feat(tenancy): harden shared cloud runtime boundaries 2026-07-24 16:22:45 +08:00
Junyan Qin 3f01ffe63b feat(tenancy): establish cloud isolation foundations 2026-07-24 16:22:45 +08:00
Junyan Qin d7adbeec1e docs: finalize cloud v2 multi-tenant decisions 2026-07-24 16:22:44 +08:00
Junyan Qin abf77cecfa docs(tenancy): refine architecture options 2026-07-24 16:22:44 +08:00
Junyan Qin 270622ae9d docs(tenancy): revise single-instance SaaS topology 2026-07-24 16:22:44 +08:00
Junyan Qin 30f414a534 docs(tenancy): record verification evidence 2026-07-24 16:22:44 +08:00
Junyan Qin 8b7ce77cec feat(tenancy): implement workspace isolation 2026-07-24 16:22:44 +08:00
Junyan Qin 37099ddf7e docs: redesign multi-tenant workspace architecture 2026-07-24 16:22:44 +08:00
Junyan Qin ee59e2d3fd Add OSS and commercial workspace boundaries 2026-07-24 16:22:44 +08:00
Junyan Qin a4550350c0 Document multi-tenant workspace architecture 2026-07-24 16:22:44 +08:00
18 changed files with 23 additions and 592 deletions
-78
View File
@@ -1,78 +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: e1b261dac45e886efc667b1096a4ec493c6a6111
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
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.JP09_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Upload release manifest and deploy
env:
HOST: ${{ secrets.JP09_HOST }}
USER: ${{ secrets.JP09_USER }}
PORT: ${{ secrets.JP09_PORT }}
run: |
remote="$USER@$HOST"
ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod'
scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/"
ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}"
-92
View File
@@ -1,92 +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
}
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
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
-161
View File
@@ -1,161 +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_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:
@@ -1,166 +0,0 @@
# LangBot 多租户数据库迁移指南
## 概述
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
## 迁移序列
```
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
0012_plugin_installation_identity → 插件实例租户绑定
0013_tenant_pgvector → RAG 向量存储隔离
0014_cloud_directory_projection → Cloud 控制平面同步
0015_cloud_core_collaboration → 协作和权限功能
```
## 执行步骤
### 1. 备份(必须)
```bash
# SQLite
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
# PostgreSQL
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
```
### 2. 执行迁移
```bash
# 停止服务
sudo -S -p '' systemctl stop langbot
# 执行迁移
python -m langbot.pkg.persistence.migration upgrade head
# 验证
python -m langbot.pkg.persistence.migration current
# 预期: 0015_cloud_core_collaboration
# 启动服务
sudo -S -p '' systemctl start langbot
```
### 3. OSS 单租户自动迁移
迁移会自动:
- 创建默认 Workspace(名称:"Default Workspace"
- 第一个用户成为 Owner
- 所有现有资源绑定到该 Workspace
### 4. 验证检查
```bash
# 检查 Workspace
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
print(f"Workspace: {ws[1]} ({ws[0]})")
# 检查资源绑定
bot_count = conn.execute(sa.text(
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
)).scalar()
print(f"Bots: {bot_count}")
EOF
```
## 回滚方案
### 完全回滚(丢失多租户数据)
```bash
# 1. 停止服务
sudo -S -p '' systemctl stop langbot
# 2. 恢复备份
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
# 3. 回退代码
git checkout v4.10.x
pip install -e .
# 4. 启动
sudo -S -p '' systemctl start langbot
```
### 降级迁移(保留数据但移除多租户)
```bash
# 警告:会移除 Workspace 表但保留资源
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
```
## 常见问题
### Q: 迁移后无法登录
```bash
# 检查用户 UUID
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
for u in users:
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
EOF
```
### Q: 资源看不见了
检查 Workspace 上下文:
```bash
# 前端请求需要带 X-Workspace-ID header
curl -H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-ID: $WORKSPACE_UUID" \
http://localhost:5200/api/v1/platform/bots
```
### Q: 迁移速度慢
```bash
# SQLite 优化
sqlite3 ~/.langbot/data/langbot.db << EOF
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
VACUUM;
EOF
```
## 性能调优
### PostgreSQL 索引
```sql
-- 迁移后创建
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
ON model_providers(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_bots_workspace
ON bots(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
ON pipelines(workspace_uuid);
```
## 预估时间
- SQLite < 100MB: 2-5 分钟
- SQLite 100MB-1GB: 5-15 分钟
- PostgreSQL: < 5 分钟(取决于数据量)
## 支持
问题反馈:https://github.com/langbot-app/LangBot/issues
**版本**: 1.0
**最后更新**: 2026-07-30
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route(
'/image/<path:image_key>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='upload_image',
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
token: str,
) -> RequestContext:
) -> None:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
@@ -168,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
return current_context
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await run_in_workspace_uow(
@@ -56,14 +56,6 @@ class KnowledgeService:
require_workspace_uuid(context)
# In new architecture, we delegate entirely to RAGManager which uses plugins.
# Legacy internal KB creation is removed.
limitation = (
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
)
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
if max_knowledge_bases >= 0:
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
if len(knowledge_bases) >= max_knowledge_bases:
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
if not knowledge_engine_plugin_id:
+2 -8
View File
@@ -138,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()
+3 -8
View File
@@ -186,14 +186,9 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
# At the final key
if key in current:
if isinstance(current[key], list):
# Convert comma-separated values while preserving the
# element type declared by a non-empty config default.
items = [item.strip() for item in env_value.split(',') if item.strip()]
if current[key]:
exemplar = current[key][0]
current[key] = [convert_value(item, exemplar) for item in items]
else:
current[key] = items
# Convert comma-separated string to list
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
elif isinstance(current[key], dict):
# Skip dict types
pass
+1 -1
View File
@@ -892,4 +892,4 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.info('Telegram adapter stopped')
self.msg_stream_id.clear()
self._form_action_titles.clear()
return True
return True
@@ -529,7 +529,7 @@ class ModelManager:
model['uuid']: model
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
created = 0
updated = 0
@@ -602,7 +602,6 @@ class ModelManager:
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -623,9 +622,7 @@ class ModelManager:
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.rerank_models_service.update_rerank_model(
context, space_model.uuid, dict(desired)
)
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
updated += 1
if created or updated:
+5 -5
View File
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async def _invitation_lock(self, token_digest: str):
"""Serialize one token while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(lock_key)
entry = self._invitation_locks.get(token_digest)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[lock_key] = entry
self._invitation_locks[token_digest] = entry
entry.users += 1
await entry.lock.acquire()
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(lock_key, None)
self._invitation_locks.pop(token_digest, None)
async def revoke_invitation(
self,
-1
View File
@@ -105,7 +105,6 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
@@ -34,7 +34,6 @@ class _Rows:
def _app():
return SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={}),
rag_mgr=SimpleNamespace(
get_all_knowledge_base_details=AsyncMock(return_value=[]),
get_knowledge_base_details=AsyncMock(return_value=None),
@@ -120,21 +119,6 @@ async def test_create_validates_schema_and_binds_context():
)
@pytest.mark.asyncio
async def test_create_enforces_workspace_knowledge_base_limit():
app = _app()
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
service = KnowledgeService(app)
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
await service.create_knowledge_base(
CONTEXT,
{'knowledge_engine_plugin_id': 'author/engine'},
)
app.rag_mgr.create_knowledge_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_rejects_guessed_uuid_and_scopes_reload():
app = _app()
@@ -862,12 +862,12 @@ class TestModelProviderServiceScanProviderModels:
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
result = await ModelProviderService(ap).scan_provider_models(
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
return_value=[{'name': 'Qwen3-Reranker-8B'}]
)
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
+1 -14
View File
@@ -228,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'),
(
-13
View File
@@ -152,19 +152,6 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
def test_override_integer_list_preserves_item_type(self):
"""Comma-separated overrides inherit the existing list item type."""
load_config = get_load_config_module()
cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}}
env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768]
assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions'])
def test_override_list_value_empty_items(self):
"""Test that empty items in comma-separated list are filtered."""
load_config = get_load_config_module()
@@ -114,10 +114,9 @@ async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_mod
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
await model_mgr.sync_new_models_from_space()
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
TEST_EXECUTION_CONTEXT,
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
@@ -1110,4 +1109,4 @@ def test_provider_not_found_error_str():
error = provider_errors.ProviderNotFoundError('test-provider')
assert str(error) == 'Provider test-provider not found'
assert error.provider_name == 'test-provider'
assert error.provider_name == 'test-provider'