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
13 changed files with 20 additions and 239 deletions
@@ -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:
+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
-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'