Compare commits

...

59 Commits

Author SHA1 Message Date
dadachann 87db6a4b24 deploy: provision Cloud models after activation 2026-08-05 15:26:45 +00:00
Hyu f59343fd5b fix: provision Cloud models after Workspace activation (#2403)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-05 23:26:23 +08:00
dadachann a3c81aac7a deploy: enable 3072-dimensional knowledge retrieval 2026-08-05 14:09:14 +00:00
Hyu 211710e24c fix: allow trusted halfvec tenant search casts (#2402)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-05 22:09:01 +08:00
dadachann f213fe3757 deploy: restore knowledge indexing 2026-08-05 13:20:29 +00:00
Hyu cdd5c6589c fix: support 3072-dimensional knowledge embeddings (#2401)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-05 21:19:57 +08:00
dadachann 31c2fd9cc8 Merge remote-tracking branch 'origin/master' into deploy/prod-runtime-control-bind-all 2026-08-05 07:14:24 +00:00
Hyu 3b4698463c chore: pin runtime control binding SDK (#2400)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-05 15:12:40 +08:00
dadachann a70d77ab57 Merge remote-tracking branch 'origin/master' into deploy/prod-debug-session-security 2026-08-04 12:00:03 +00:00
Hyu edd6cad449 chore: pin fenced debug runtime sessions (#2396)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-04 19:59:51 +08:00
dadachann 519b7ed39f Merge remote-tracking branch 'origin/master' into deploy/prod-workspace-debug 2026-08-04 11:20:21 +00:00
Hyu d78546967c feat: expose Workspace-scoped rotating plugin debug keys (#2395)
* feat: add Workspace-scoped rotating plugin debug tokens

* chore: pin formatted Workspace debug runtime

* chore: pin merged Workspace debug runtime

* chore: pin tenant-safe debug runtime

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-04 19:20:06 +08:00
dadachann 002317d00a Merge remote-tracking branch origin/master into deploy/prod-telemetry 2026-08-04 09:27:57 +00:00
Hyu c08bfc8ced feat: report independent instance and workspace identities (#2394)
* feat: report independent instance and workspace identities

* test: include workspace in OAuth callback fixture

* ci: pin production cloud adapter to Space release

* fix: preserve authenticated Workspace telemetry attribution

* ci: pin production cloud adapter to final Space release

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-04 17:27:23 +08:00
dadachann 5671de49e1 Merge remote-tracking branch origin/master into deploy/prod-member-emails 2026-08-04 03:49:17 +00:00
Hyu 7820949d3a fix(workspace): show member emails (#2393)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-04 11:48:43 +08:00
dadachann e82dce7e42 Merge remote-tracking branch 'origin/master' into deploy/prod-tooltip-wrap-2391 2026-08-03 13:27:05 +00:00
Hyu e263a5d1d7 fix(web): use natural tooltip wrapping (#2391)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 21:26:55 +08:00
dadachann 792fbfbe10 Merge remote-tracking branch 'origin/master' into deploy/prod-tooltip-2390 2026-08-03 12:11:05 +00:00
Hyu 6bad7bcffc fix(web): keep extension market navigable at quota (#2390)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 20:10:46 +08:00
dadachann 3df36dd63f Merge remote-tracking branch 'origin/master' into deploy/prod-quota-2389 2026-08-03 11:09:14 +00:00
Hyu f0b2c103c1 fix(web): disable quota-reached create actions (#2389)
* fix(web): disable quota-reached create actions

* fix(web): close quota review gaps

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 19:08:47 +08:00
dadachann f48dc847ff [verified] fix: harden OSS and Cloud workspace UI (#2387)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 05:29:55 +00:00
Hyu 3101c9be6a [verified] fix: harden OSS and Cloud workspace UI (#2387)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 13:27:38 +08:00
Hyu 1e6e4c0ca7 fix(cloud): show owner model balance and enforce single owner (#2384) (#2385)
* fix(cloud): show owner model balance and enforce single owner

* fix(migrations): create owner index idempotently

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 02:14:43 +08:00
Hyu 9b57183a99 fix(cloud): show owner model balance and enforce single owner (#2384)
* fix(cloud): show owner model balance and enforce single owner

* fix(migrations): create owner index idempotently

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-03 01:53:40 +08:00
Hyu 408c8031d4 Merge pull request #2383 from langbot-app/sync/cloud-fixes-to-master
merge: sync Cloud production fixes to master
2026-08-02 17:24:48 +08:00
dadachann 0e6cca4690 merge: sync Cloud production fixes to master 2026-08-02 09:19:41 +00:00
Hyu 2cc6d10f33 fix(cloud): accept invitations with current account (#2382)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 17:13:26 +08:00
dadachann a7a7218afe fix(cloud): accept invitations with current account 2026-08-02 09:08:49 +00:00
Hyu a67728c163 fix cloud monitoring and invitation sign-in (#2381)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 16:39:14 +08:00
Hyu e9c9e896c6 fix(cloud): preserve pipeline routing in debug chat (#2380)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 01:49:17 +08:00
Hyu e2331c4967 fix(cloud): restore plugins and pipeline execution (#2379)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 01:34:11 +08:00
Hyu 0ccbcd5f5f fix(migrations): preserve published Cloud revision head (#2375)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 00:56:20 +08:00
Hyu c5aada494d fix(cloud): treat workspace owners as Space-bound (#2378)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-02 00:33:46 +08:00
Hyu e36e3aaea8 fix(cloud): accept null model abilities (#2377)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-01 18:50:47 +08:00
Hyu d64278ab3f feat(cloud): provision workspace model catalog (#2376)
* feat(cloud): provision workspace model catalog

* ci(cloud): pin model catalog adapter source

* fix: make cloud model catalog sync recoverable

* ci: pin cloud adapter source for release

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-01 18:16:02 +08:00
dadachann 161ea9b3eb fix(cloud): restore fragment-based Space launch callback 2026-08-01 04:41:44 +00:00
dadachann c7d14676fc fix(cloud): restore disabled Box production mode 2026-08-01 04:11:17 +00:00
dadachann 2456bf1350 fix(migrations): preserve published Cloud revision head 2026-07-31 18:35:21 +00:00
dadachann 05a941ff16 Merge remote-tracking branch 'origin/deploy/prod' into release/cloud-monitoring-prod 2026-07-31 18:18:46 +00:00
dadachann 0330788d14 chore(prod): release workspace monitoring 2026-07-31 18:18:46 +00:00
Hyu d8ab0ba567 feat(telemetry): report workspace execution generation (#2374)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-01 02:10:59 +08:00
dadachann 473ba573a3 fix(cloud): retain launch replay records through clock skew 2026-07-30 21:37:06 +00:00
dadachann 93dbd3541e fix(cloud): make direct launch replay-safe 2026-07-30 21:02:38 +00:00
dadachann a5a26f81ee feat(auth): accept direct Space launch assertions 2026-07-30 20:05:17 +00:00
dadachann 92d9db8f95 chore(plugin): pin SDK 0.5.0 2026-07-30 20:05:17 +00:00
dadachann 59db012594 feat(cloud): enforce workspace resource quotas 2026-07-30 18:48:42 +00:00
dadachann 88f328066b fix(cloud): keep runtime sdk ahead of plugin dependencies 2026-07-30 16:42:01 +00:00
dadachann d155d9d5a8 fix(cloud): allow explicitly disabled box runtime 2026-07-30 15:42:49 +00:00
dadachann dd95545309 fix(prod): configure shared Box runtime 2026-07-30 15:17:17 +00:00
dadachann 9066c25729 fix(deploy): let migration own exact runtime ACLs 2026-07-30 14:58:31 +00:00
dadachann 6d2e9d3d72 fix(deploy): do not start disabled Box runtime 2026-07-30 14:48:51 +00:00
dadachann a0b85e11fd fix(deploy): keep runtime role schema read-only 2026-07-30 14:44:57 +00:00
dadachann 122d8fa659 fix(deploy): retry transient image pull failures 2026-07-30 14:34:20 +00:00
dadachann ace8cc67f2 fix(config): preserve typed list environment overrides 2026-07-30 14:26:41 +00:00
dadachann 52c0772806 fix(cloud): pin Space production URL and deployment health 2026-07-30 14:21:04 +00:00
dadachann d5044c2f1e fix(ci): authenticate cloud adapter checkout 2026-07-30 14:08:58 +00:00
dadachann 7baa89254c ops(cloud): deploy exact production stack to jp09 2026-07-30 13:58:37 +00:00
110 changed files with 7908 additions and 4235 deletions
+59
View File
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,97 @@
#!/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
@@ -0,0 +1,162 @@
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:
+3 -4
View File
@@ -14,8 +14,8 @@ services:
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Shared with the langbot service and sent only as a WebSocket handshake # Optional. Leave unset on both OSS services, or set the same value on
# header. Generate with: openssl rand -hex 32 # both to protect the control WebSocket. Generate with: openssl rand -hex 32
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-} - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# Process-wide admission for every asyncio.to_thread() call. # Process-wide admission for every asyncio.to_thread() call.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8} - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -77,8 +77,7 @@ services:
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Must match langbot_plugin_runtime. Empty/missing values make the # Optional. Leave unset on both OSS services, or match plugin Runtime.
# external control channel fail closed.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-} - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# Must match the value supplied to langbot_box. The token is sent only # Must match the value supplied to langbot_box. The token is sent only
# in WebSocket handshake headers, never in URLs or action payloads. # in WebSocket handshake headers, never in URLs or action payloads.
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.6" version = "4.10.7"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -71,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3", "pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162", "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
+7 -6
View File
@@ -32,12 +32,13 @@ The `all` / `box` profile starts three services:
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`; the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
never put it in `box.runtime.endpoint` or commit it to config. never put it in `box.runtime.endpoint` or commit it to config.
Every Compose deployment also needs one A Compose deployment may optionally set
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it `langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
before `docker compose up`; the external Plugin Runtime fails closed when the defaults to leaving it unset on both sides. If enabled, generate one value with
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control` `openssl rand -hex 32`; configuring only one side causes the control connection
Secret shown in `docker/kubernetes.yaml`. to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
`docker/kubernetes.yaml`.
With Box off, the dashboard/skills list stays visible (read-only) but sandbox With Box off, the dashboard/skills list stays visible (read-only) but sandbox
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false` tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
-2
View File
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
WORKSPACE_VIEW = 'workspace.view' WORKSPACE_VIEW = 'workspace.view'
WORKSPACE_UPDATE = 'workspace.update' WORKSPACE_UPDATE = 'workspace.update'
WORKSPACE_DELETE = 'workspace.delete' WORKSPACE_DELETE = 'workspace.delete'
OWNER_TRANSFER = 'owner.transfer'
MEMBER_VIEW = 'member.view' MEMBER_VIEW = 'member.view'
MEMBER_INVITE = 'member.invite' MEMBER_INVITE = 'member.invite'
MEMBER_UPDATE_ROLE = 'member.update_role' MEMBER_UPDATE_ROLE = 'member.update_role'
@@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
if permission if permission
not in { not in {
Permission.WORKSPACE_DELETE, Permission.WORKSPACE_DELETE,
Permission.OWNER_TRANSFER,
Permission.BILLING_LINK_MANAGE, Permission.BILLING_LINK_MANAGE,
} }
), ),
@@ -62,7 +62,6 @@ class AuthType(enum.Enum):
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset( _SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
{ {
Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value, Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value, Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value, Permission.MEMBER_UPDATE_ROLE.value,
@@ -392,8 +392,8 @@ class PluginsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key""" """Get plugin debug information including debug URL and key"""
await self._require_authenticated_plugin_runtime_context(request_context) execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info() debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
# Get debug URL from config # Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {}) plugin_config = self.ap.instance_config.data.get('plugin', {})
@@ -403,6 +403,7 @@ class PluginsRouterGroup(group.RouterGroup):
data={ data={
'debug_url': debug_url, 'debug_url': debug_url,
'plugin_debug_key': debug_info.get('plugin_debug_key', ''), 'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
'expires_at': debug_info.get('expires_at', ''),
} }
) )
@@ -1,6 +1,7 @@
import quart import quart
import argon2 import argon2
import asyncio import asyncio
import datetime
import uuid import uuid
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
@@ -218,7 +219,22 @@ class UserRouterGroup(group.RouterGroup):
try: try:
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens # Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code) 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,
)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
@@ -231,7 +247,6 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in access_token, refresh_token, expires_in
) )
launch_workspace_uuid = consumed_state.launch_workspace_uuid
if launch_workspace_uuid: if launch_workspace_uuid:
try: try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace( access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
@@ -285,8 +300,25 @@ class UserRouterGroup(group.RouterGroup):
request_context.workspace_uuid, request_context.workspace_uuid,
) )
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid) owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
owner_space_bound = bool(owner and owner.space_account_uuid) cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None 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
)
return self.success( return self.success(
data={ data={
'credits': credits, 'credits': credits,
@@ -302,8 +334,10 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'initialized': False}) return self.success(data={'initialized': False})
capabilities = await self.ap.user_service.get_login_capabilities() capabilities = await self.ap.user_service.get_login_capabilities()
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode:
capabilities['password_login_enabled'] = False capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
return self.success(data={'initialized': True, **capabilities}) return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -30,12 +30,14 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
def _membership_payload( def _membership_payload(
membership: WorkspaceMembership, membership: WorkspaceMembership,
*, *,
display_name: str,
email: str, email: str,
) -> dict[str, typing.Any]: ) -> dict[str, typing.Any]:
return { return {
'uuid': membership.uuid, 'uuid': membership.uuid,
'workspace_uuid': membership.workspace_uuid, 'workspace_uuid': membership.workspace_uuid,
'account_uuid': membership.account_uuid, 'account_uuid': membership.account_uuid,
'display_name': display_name,
'email': email, 'email': email,
'role': membership.role, 'role': membership.role,
'status': membership.status, 'status': membership.status,
@@ -94,7 +96,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
workspaces.append( workspaces.append(
{ {
'workspace': _workspace_payload(access.workspace), 'workspace': _workspace_payload(access.workspace),
'membership': _membership_payload(access.membership, email=account.user), 'membership': _membership_payload(
access.membership,
display_name=account.user,
email=account.normalized_email,
),
'permissions': sorted(permissions_for_role(access.membership.role)), 'permissions': sorted(permissions_for_role(access.membership.role)),
'placement_generation': access.execution.placement_generation, 'placement_generation': access.execution.placement_generation,
'plan_name': plan_name, 'plan_name': plan_name,
@@ -137,6 +143,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
'uuid': None, 'uuid': None,
'workspace_uuid': request_context.workspace_uuid, 'workspace_uuid': request_context.workspace_uuid,
'account_uuid': None, 'account_uuid': None,
'display_name': None,
'email': None, 'email': None,
'role': 'owner', 'role': 'owner',
'status': 'active', 'status': 'active',
@@ -154,7 +161,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
return self.success( return self.success(
data={ data={
'workspace': _workspace_payload(workspace), 'workspace': _workspace_payload(workspace),
'membership': _membership_payload(membership, email=account.user), 'membership': _membership_payload(
membership,
display_name=account.user,
email=account.normalized_email,
),
'permissions': sorted(request_context.workspace.permissions), 'permissions': sorted(request_context.workspace.permissions),
'placement_generation': request_context.placement_generation, 'placement_generation': request_context.placement_generation,
'plan_name': plan_name, 'plan_name': plan_name,
@@ -283,7 +294,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
data={ data={
'member': _membership_payload( 'member': _membership_payload(
member, member,
email=account.user if account is not None else '', display_name=account.user if account is not None else '',
email=account.normalized_email if account is not None else '',
) )
} }
) )
@@ -302,7 +314,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
@staticmethod @staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]: def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
return _membership_payload(view.membership, email=view.email) return _membership_payload(
view.membership,
display_name=view.display_name,
email=view.email,
)
@group.group_class('invitations', '/api/v1/invitations') @group.group_class('invitations', '/api/v1/invitations')
+70 -5
View File
@@ -5,6 +5,7 @@ import uuid
import sqlalchemy import sqlalchemy
from langbot_plugin.api.entities.builtin.provider import message as provider_message 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 ....core import app
from ....entity.persistence import model as persistence_model from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline from ....entity.persistence import pipeline as persistence_pipeline
@@ -113,6 +114,23 @@ async def _require_workspace_provider(
return 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( async def _require_runtime_provider(
ap: app.Application, ap: app.Application,
context: TenantContext, context: TenantContext,
@@ -213,6 +231,7 @@ class LLMModelsService:
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['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 _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)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
@@ -291,11 +310,17 @@ class LLMModelsService:
return model_dict 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""" """Update an existing LLM model"""
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True) existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
if existing_model is None: if existing_model is None:
raise WorkspaceNotFoundError('Model not found') 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 = model_data.copy()
model_data.pop('uuid', None) model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None) model_data.pop('workspace_uuid', None)
@@ -321,6 +346,7 @@ class LLMModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, 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') await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
@@ -355,6 +381,11 @@ class LLMModelsService:
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None: async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an LLM model""" """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( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid), sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
@@ -448,7 +479,10 @@ class EmbeddingModelsService:
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized] return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_embedding_model( 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: ) -> str:
"""Create a new embedding model""" """Create a new embedding model"""
model_data = model_data.copy() model_data = model_data.copy()
@@ -472,6 +506,7 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['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 _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -530,11 +565,17 @@ class EmbeddingModelsService:
return model_dict 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""" """Update an existing embedding model"""
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True) existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
if existing_model is None: if existing_model is None:
raise WorkspaceNotFoundError('Model not found') 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 = model_data.copy()
model_data.pop('uuid', None) model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None) model_data.pop('workspace_uuid', None)
@@ -559,6 +600,7 @@ class EmbeddingModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, 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') await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
@@ -593,6 +635,11 @@ class EmbeddingModelsService:
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None: async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an embedding model""" """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( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.delete(persistence_model.EmbeddingModel).where( sqlalchemy.delete(persistence_model.EmbeddingModel).where(
@@ -685,7 +732,12 @@ class RerankModelsService:
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models] 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] 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""" """Create a new rerank model"""
model_data = model_data.copy() model_data = model_data.copy()
if not preserve_uuid: if not preserve_uuid:
@@ -708,6 +760,7 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _require_workspace_provider(self.ap, context, model_data['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 _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -766,11 +819,17 @@ class RerankModelsService:
return model_dict 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""" """Update an existing rerank model"""
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True) existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
if existing_model is None: if existing_model is None:
raise WorkspaceNotFoundError('Model not found') 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 = model_data.copy()
model_data.pop('uuid', None) model_data.pop('uuid', None)
model_data.pop('workspace_uuid', None) model_data.pop('workspace_uuid', None)
@@ -795,6 +854,7 @@ class RerankModelsService:
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid']) provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
await _require_workspace_provider(self.ap, context, 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') await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
@@ -829,6 +889,11 @@ class RerankModelsService:
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None: async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete a rerank model""" """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( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.delete(persistence_model.RerankModel).where( sqlalchemy.delete(persistence_model.RerankModel).where(
@@ -5,6 +5,7 @@ import traceback
import sqlalchemy import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app from ....core import app
from ....entity.persistence import model as persistence_model from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError from ....workspace.errors import WorkspaceNotFoundError
@@ -20,6 +21,20 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap 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 @staticmethod
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]: def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
if api_keys is None: if api_keys is None:
@@ -99,6 +114,8 @@ class ModelProviderService:
async def create_provider(self, context: TenantContext, provider_data: dict) -> str: async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
"""Create a new provider""" """Create a new provider"""
provider_data = provider_data.copy() 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['uuid'] = str(uuid.uuid4())
provider_data['workspace_uuid'] = require_workspace_uuid(context) provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys( provider_data['api_keys'] = self._normalize_api_keys(
@@ -115,7 +132,10 @@ class ModelProviderService:
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None: async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider""" """Update an existing provider"""
await self._assert_provider_mutable(context, provider_uuid)
provider_data = provider_data.copy() 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('uuid', None)
provider_data.pop('workspace_uuid', None) provider_data.pop('workspace_uuid', None)
if 'api_keys' in provider_data: if 'api_keys' in provider_data:
@@ -145,6 +165,7 @@ class ModelProviderService:
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None: async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)""" """Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
workspace_uuid = require_workspace_uuid(context) workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider # Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async( llm_result = await self.ap.persistence_mgr.execute_async(
@@ -245,6 +266,8 @@ class ModelProviderService:
api_keys: list, api_keys: list,
) -> str: ) -> str:
"""Find existing provider or create new one""" """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) workspace_uuid = require_workspace_uuid(context)
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True)) api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
+18 -2
View File
@@ -59,6 +59,10 @@ class SpaceService:
result_list = result.all() result_list = result.all()
return result_list[0] if result_list else None 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: 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.""" """Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
user_obj = await self._get_user_by_email(user_email) user_obj = await self._get_user_by_email(user_email)
@@ -117,7 +121,12 @@ class SpaceService:
params['state'] = state params['state'] = state
return f'{authorize_url}?{urlencode(params)}' return f'{authorize_url}?{urlencode(params)}'
async def exchange_oauth_code(self, code: str) -> typing.Dict: async def exchange_oauth_code(
self,
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
) -> typing.Dict:
"""Exchange OAuth authorization code for tokens""" """Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants from langbot.pkg.utils import constants
@@ -127,7 +136,14 @@ class SpaceService:
session = httpclient.get_session() session = httpclient.get_session()
async with session.post( async with session.post(
f'{space_url}/api/v1/accounts/oauth/token', f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code, 'instance_id': constants.instance_id}, 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 {},
},
) as response: ) as response:
if response.status != 200: if response.status != 200:
error = await httpclient.read_text_limited(response) error = await httpclient.read_text_limited(response)
+21 -2
View File
@@ -779,8 +779,27 @@ class UserService:
local_account = await self.get_user_by_email(user_email) local_account = await self.get_user_by_email(user_email)
if local_account is None: if local_account is None:
raise ValueError('User not found') raise ValueError('User not found')
# Exchange code for tokens # Exchange code for tokens and bind both installation and the active
token_data = await self.ap.space_service.exchange_oauth_code(code) # 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)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
+14 -3
View File
@@ -13,11 +13,12 @@ from typing import Any, Protocol, runtime_checkable
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
from .model_catalog import CloudModelCatalogProvider
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap' CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
REQUIRED_TENANT_ISOLATION_VERSION = 2 REQUIRED_TENANT_ISOLATION_VERSION = 2
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536}) SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536, 3072})
class CloudBootstrapError(RuntimeError): class CloudBootstrapError(RuntimeError):
@@ -50,6 +51,7 @@ class OpenSourceDeployment:
) )
directory_provider: None = None directory_provider: None = None
manifest_provider: None = None manifest_provider: None = None
model_catalog_provider: None = None
persistence_mode: str = 'oss_compat' persistence_mode: str = 'oss_compat'
required_vector_backend: str | None = None required_vector_backend: str | None = None
@@ -80,6 +82,7 @@ class VerifiedCloudDeployment:
entitlement_provider: EntitlementProvider entitlement_provider: EntitlementProvider
directory_provider: DirectoryProjectionProvider directory_provider: DirectoryProjectionProvider
manifest_provider: CloudManifestProvider manifest_provider: CloudManifestProvider
model_catalog_provider: CloudModelCatalogProvider
verification_key_id: str verification_key_id: str
mode: str = dataclasses.field(default='cloud', init=False) mode: str = dataclasses.field(default='cloud', init=False)
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False) workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
@@ -110,6 +113,8 @@ class VerifiedCloudDeployment:
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter') raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
if not isinstance(self.manifest_provider, CloudManifestProvider): if not isinstance(self.manifest_provider, CloudManifestProvider):
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter') 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: def validate_instance_config(self, config: dict[str, Any]) -> None:
try: try:
@@ -138,8 +143,14 @@ class VerifiedCloudDeployment:
if plugin_worker.get('require_hard_limits') is not True: if plugin_worker.get('require_hard_limits') is not True:
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true') raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
box_config = config.get('box', {}) box_config = config.get('box', {})
if box_config.get('enabled') is not True: box_enabled = box_config.get('enabled')
raise CloudBootstrapError('Cloud runtime requires box.enabled=true') 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('backend') != 'nsjail': if box_config.get('backend') != 'nsjail':
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail') raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip() runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
@@ -358,6 +358,7 @@ class DirectoryProjectionService:
await self._reconcile_entitlement_snapshot_set(snapshot) await self._reconcile_entitlement_snapshot_set(snapshot)
self._publish_runtime_execution_projection(snapshot.workspaces) self._publish_runtime_execution_projection(snapshot.workspaces)
self._request_model_catalog_sync()
self._record_batch_cardinality( self._record_batch_cardinality(
active_workspaces=active_workspace_count, active_workspaces=active_workspace_count,
workspaces=workspace_count, workspaces=workspace_count,
@@ -466,6 +467,7 @@ class DirectoryProjectionService:
returned.values(), returned.values(),
affected_workspace_uuids=requested, affected_workspace_uuids=requested,
) )
self._request_model_catalog_sync()
self._record_batch_cardinality( self._record_batch_cardinality(
active_workspaces=active_workspace_count, active_workspaces=active_workspace_count,
workspaces=workspace_count, workspaces=workspace_count,
@@ -475,6 +477,14 @@ class DirectoryProjectionService:
self._record_success() self._record_success()
self._consumer_cursor = batch.cursor self._consumer_cursor = batch.cursor
def _request_model_catalog_sync(self) -> None:
"""Wake model provisioning after a committed directory change."""
service = getattr(self.ap, 'cloud_model_catalog_service', None)
request_sync = getattr(service, 'request_sync', None)
if callable(request_sync):
request_sync()
def _publish_runtime_execution_projection( def _publish_runtime_execution_projection(
self, self,
workspaces: Iterable[DirectoryWorkspace], workspaces: Iterable[DirectoryWorkspace],
+337
View File
@@ -0,0 +1,337 @@
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] = {}
self._sync_requested = asyncio.Event()
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)
def request_sync(self) -> None:
"""Wake the catalog loop after a directory Workspace change."""
self._sync_requested.set()
async def run(self) -> None:
while True:
try:
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
except TimeoutError:
pass
self._sync_requested.clear()
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}')
+9 -3
View File
@@ -54,6 +54,7 @@ from ..cloud import launch as cloud_launch_module
from ..cloud import support_admin as cloud_support_admin_module from ..cloud import support_admin as cloud_support_admin_module
from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import directory_projection as cloud_directory_projection_module
from ..cloud import entitlements as cloud_entitlements_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 from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
@@ -142,13 +143,12 @@ class Application:
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = 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 manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | 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 vector_db_mgr: vectordb_mgr.VectorDBManager = None
http_ctrl: http_controller.HTTPController = None http_ctrl: http_controller.HTTPController = None
@@ -306,6 +306,12 @@ class Application:
name='cloud-directory-projection', name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION], 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: if self.manifest_refresh_service is not None:
self.task_mgr.create_task( self.task_mgr.create_task(
self.manifest_refresh_service.run(), self.manifest_refresh_service.run(),
+11
View File
@@ -46,6 +46,7 @@ from ...cloud import support_admin as cloud_support_admin_module
from ...cloud.directory import directory_projection_limits_from_config from ...cloud.directory import directory_projection_limits_from_config
from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.directory_projection import DirectoryProjectionService
from ...cloud.entitlements import EntitlementResolver from ...cloud.entitlements import EntitlementResolver
from ...cloud.model_catalog import CloudModelCatalogSyncService
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from ...api.http.authz import WorkspaceRequiredError from ...api.http.authz import WorkspaceRequiredError
@@ -176,6 +177,16 @@ class BuildAppStage(stage.BootingStage):
# of repeating tenant validation for every manager. # of repeating tenant validation for every manager.
await workspace_service_inst.prime_startup_execution_bindings() 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_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
ap, ap,
workspace_service_inst, workspace_service_inst,
@@ -163,6 +163,13 @@ class WorkspaceMembership(Base):
__table_args__ = ( __table_args__ = (
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'), sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'), 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( sqlalchemy.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')", "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role', name='ck_workspace_memberships_role',
@@ -0,0 +1,57 @@
"""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)
@@ -0,0 +1,21 @@
"""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
@@ -0,0 +1,80 @@
"""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')
@@ -0,0 +1,43 @@
"""enable 3072-dimensional pgvector embeddings
Revision ID: 001a_pgvector_dimension_3072
Revises: 0019_single_workspace_owner
Create Date: 2026-08-05
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '001a_pgvector_dimension_3072'
down_revision = '0019_single_workspace_owner'
branch_labels = None
depends_on = None
_TABLE = 'langbot_vectors'
_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
_INDEX = 'ix_langbot_vectors_hnsw_cosine_3072'
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
return
op.drop_constraint(_CHECK, _TABLE, type_='check')
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536, 3072)')
op.execute(
sa.text(
f'CREATE INDEX {_INDEX} ON {_TABLE} USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops) WHERE embedding_dimension = 3072'
)
)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
return
count = conn.scalar(sa.text(f'SELECT COUNT(*) FROM {_TABLE} WHERE embedding_dimension = 3072'))
if count:
raise RuntimeError('Cannot disable 3072-dimensional pgvector while matching embeddings exist')
op.drop_index(_INDEX, table_name=_TABLE)
op.drop_constraint(_CHECK, _TABLE, type_='check')
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536)')
+6 -4
View File
@@ -98,7 +98,7 @@ _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources' _RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid' _OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432 _RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536) _PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
_RUNTIME_SCHEMA = 'public' _RUNTIME_SCHEMA = 'public'
_ALEMBIC_RUNTIME_TABLE = 'alembic_version' _ALEMBIC_RUNTIME_TABLE = 'alembic_version'
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'}) _RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
@@ -1356,14 +1356,16 @@ class PersistenceManager:
index = by_index.get(index_name) index = by_index.get(index_name)
index_definition = normalized(None if index is None else index['definition']) index_definition = normalized(None if index is None else index['definition'])
predicate = normalized(None if index is None else index['predicate']) predicate = normalized(None if index is None else index['predicate'])
vector_type = 'halfvec' if dimension > 2000 else 'vector'
operator_class = f'{vector_type}_cosine_ops'
if ( if (
index is None index is None
or index['access_method'] != 'hnsw' or index['access_method'] != 'hnsw'
or index['is_valid'] is not True or index['is_valid'] is not True
or index['is_ready'] is not True or index['is_ready'] is not True
or f'vector({dimension})' not in index_definition or f'{vector_type}({dimension})' not in index_definition
or f'(embedding)::vector({dimension})' not in index_definition or f'(embedding)::{vector_type}({dimension})' not in index_definition
or 'vector_cosine_ops' not in index_definition or operator_class not in index_definition
or predicate.strip('() ') != f'embedding_dimension = {dimension}' or predicate.strip('() ') != f'embedding_dimension = {dimension}'
): ):
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid') raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
+4 -4
View File
@@ -13,7 +13,7 @@ import typing
import sqlalchemy import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy.orm as sqlalchemy_orm import sqlalchemy.orm as sqlalchemy_orm
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now, 'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum, 'sum': sqlalchemy.sql.functions.sum,
} }
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'}) _ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = ( _ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase, sqlalchemy.sql.dml.UpdateBase,
@@ -281,7 +281,7 @@ def _validate_scoped_sql_type(
return return
seen.add(identity) seen.add(identity)
if type(sql_type) is Vector: if type(sql_type) in {Vector, HALFVEC}:
return return
if not type(sql_type).__module__.startswith('sqlalchemy.'): if not type(sql_type).__module__.startswith('sqlalchemy.'):
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements') raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
@@ -462,7 +462,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute: if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters') raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector: if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
raise ScopedSessionTransactionError( raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search' 'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
) )
+1 -3
View File
@@ -132,9 +132,7 @@ class Controller:
break break
if selected_query: # 找到 if not selected_query: # 找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
queries.remove(selected_query)
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
await self.ap.query_pool.condition.wait() await self.ap.query_pool.condition.wait()
continue continue
@@ -707,28 +707,37 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if len(listener_tasks) >= 100: if len(listener_tasks) >= 100:
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message') await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
return return
token = _current_pipeline_uuid.set(pipeline_uuid) listener = typing.cast(
try: typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
task_manager = getattr(self.ap, 'task_mgr', None) listeners[event.__class__],
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: async def run_listener():
listener_task = task_manager.create_task( token = _current_pipeline_uuid.set(pipeline_uuid)
listeners[event.__class__](event, callback_adapter), try:
kind='websocket-message', await listener(event, callback_adapter)
name=f'websocket-message-{connection.connection_id}', finally:
scopes=[ _current_pipeline_uuid.reset(token)
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM, listener_coro = run_listener()
], task_manager = getattr(self.ap, 'task_mgr', None)
instance_uuid=connection.instance_uuid, if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
workspace_uuid=connection.workspace_uuid, listener_task = asyncio.create_task(listener_coro)
placement_generation=connection.placement_generation, else:
).task listener_task = task_manager.create_task(
listener_tasks.add(listener_task) listener_coro,
listener_task.add_done_callback(self._listener_task_done) kind='websocket-message',
finally: name=f'websocket-message-{connection.connection_id}',
_current_pipeline_uuid.reset(token) 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)
def get_websocket_messages( def get_websocket_messages(
self, self,
+4 -2
View File
@@ -251,6 +251,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]: def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
if not self._control_token and allow_generate: if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48) self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
return {}
try: try:
self._control_token = validate_runtime_secret( self._control_token = validate_runtime_secret(
self._control_token, self._control_token,
@@ -1968,11 +1970,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
with runtime_handler.installation_scope(binding): with runtime_handler.installation_scope(binding):
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body) return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]: async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL""" """Get debug information including debug key and WS URL"""
if not self.is_enable_plugin or not self._runtime_available(): if not self.is_enable_plugin or not self._runtime_available():
return {} return {}
return await self._runtime_handler().get_debug_info() return await self._runtime_handler().get_debug_info(execution_context)
async def emit_event( async def emit_event(
self, self,
+7 -7
View File
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
) )
return result return result
async def get_debug_info(self) -> dict[str, Any]: async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL""" """Get debug information including debug key and WS URL"""
with self.installation_scope(None): result = await self.call_action(
result = await self.call_action( LangBotToRuntimeAction.GET_DEBUG_INFO,
LangBotToRuntimeAction.GET_DEBUG_INFO, {},
{}, timeout=10,
timeout=10, action_context=execution_context,
) )
return result return result
# ================= RAG Capability Callers (LangBot -> Runtime) ================= # ================= RAG Capability Callers (LangBot -> Runtime) =================
+20
View File
@@ -37,6 +37,7 @@ class WorkspaceResourceSnapshot(typing.TypedDict):
extension_count: int extension_count: int
skill_count: int skill_count: int
adapters: list[str] adapters: list[str]
execution_generation: int
async def _count( async def _count(
@@ -81,6 +82,7 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
'extension_count': 0, 'extension_count': 0,
'skill_count': 0, 'skill_count': 0,
'adapters': [], 'adapters': [],
'execution_generation': binding.placement_generation,
} }
for binding in bindings for binding in bindings
} }
@@ -118,6 +120,7 @@ async def build_heartbeat_payload(
ap: core_app.Application, ap: core_app.Application,
*, *,
workspace_uuid: str, workspace_uuid: str,
workspace_create_ts: int = 0,
workspace_resource: WorkspaceResourceSnapshot | None = None, workspace_resource: WorkspaceResourceSnapshot | None = None,
) -> dict: ) -> dict:
"""Collect one anonymous Workspace profile snapshot.""" """Collect one anonymous Workspace profile snapshot."""
@@ -210,7 +213,9 @@ async def build_heartbeat_payload(
'event_type': 'instance_heartbeat', 'event_type': 'instance_heartbeat',
'query_id': '', 'query_id': '',
'version': constants.semantic_version, 'version': constants.semantic_version,
'instance_id': constants.instance_id,
'workspace_uuid': workspace_uuid, 'workspace_uuid': workspace_uuid,
'workspace_create_ts': workspace_create_ts,
'instance_create_ts': constants.instance_create_ts, 'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition, 'edition': constants.edition,
'features': features, 'features': features,
@@ -218,10 +223,24 @@ 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]: async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
"""Build one heartbeat per active Workspace.""" """Build one heartbeat per active Workspace."""
bindings = await ap.workspace_service.list_active_execution_bindings() bindings = await ap.workspace_service.list_active_execution_bindings()
workspace_uuids = sorted({binding.workspace_uuid for binding in 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 = { resources = {
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings) resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
} }
@@ -229,6 +248,7 @@ async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
await build_heartbeat_payload( await build_heartbeat_payload(
ap, ap,
workspace_uuid=workspace_uuid, workspace_uuid=workspace_uuid,
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
workspace_resource=resources.get(workspace_uuid), workspace_resource=resources.get(workspace_uuid),
) )
for workspace_uuid in workspace_uuids for workspace_uuid in workspace_uuids
+8 -2
View File
@@ -4,13 +4,19 @@ import typing
class WorkspaceExecutionContext(typing.Protocol): class WorkspaceExecutionContext(typing.Protocol):
@property
def instance_uuid(self) -> str: ...
@property @property
def workspace_uuid(self) -> str: ... def workspace_uuid(self) -> str: ...
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]: def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
"""Build the canonical telemetry identity for one Workspace execution.""" """Build both first-class telemetry identities for one execution."""
instance_id = execution_context.instance_uuid.strip()
workspace_uuid = execution_context.workspace_uuid.strip() workspace_uuid = execution_context.workspace_uuid.strip()
if not instance_id:
raise ValueError('Telemetry execution instance ID is empty')
if not workspace_uuid: if not workspace_uuid:
raise ValueError('Telemetry execution Workspace UUID is empty') raise ValueError('Telemetry execution Workspace UUID is empty')
return {'workspace_uuid': workspace_uuid} return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
+24 -5
View File
@@ -136,12 +136,31 @@ class TelemetryManager:
try: try:
# Use asyncio.wait_for to ensure we always bound the total time # Use asyncio.wait_for to ensure we always bound the total time
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip() telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
headers: dict[str, str] = {}
if telemetry_token: if telemetry_token:
request = client.post( headers['X-LangBot-Telemetry-Token'] = telemetry_token
url, else:
json=sanitized, workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
headers={'X-LangBot-Telemetry-Token': telemetry_token}, 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)
else: else:
request = client.post(url, json=sanitized) request = client.post(url, json=sanitized)
resp = await asyncio.wait_for(request, timeout=10 + 1) resp = await asyncio.wait_for(request, timeout=10 + 1)
+1 -1
View File
@@ -67,7 +67,7 @@ class VectorDBManager:
use_business_database = pgvector_config.get('use_business_database', False) use_business_database = pgvector_config.get('use_business_database', False)
allowed_dimensions = pgvector_config.get( allowed_dimensions = pgvector_config.get(
'allowed_dimensions', 'allowed_dimensions',
[384, 512, 768, 1024, 1536], [384, 512, 768, 1024, 1536, 3072],
) )
common_options = { common_options = {
'use_business_database': use_business_database, 'use_business_database': use_business_database,
+8 -3
View File
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterator
from typing import Any from typing import Any
import sqlalchemy import sqlalchemy
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy.dialects.postgresql import insert as postgresql_insert from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
@@ -18,7 +18,7 @@ from langbot.pkg.vector.vdb import VectorDatabase
Base = declarative_base() Base = declarative_base()
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536) DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
# pgvector schema only stores these metadata fields. # pgvector schema only stores these metadata fields.
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'} _PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
@@ -321,7 +321,12 @@ class PgVectorDatabase(VectorDatabase):
if len(query_embedding) != scope.embedding_dimension: if len(query_embedding) != scope.embedding_dimension:
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}') raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension)) typed_embedding = sqlalchemy.cast(
PgVectorEntry.embedding,
HALFVEC(scope.embedding_dimension)
if scope.embedding_dimension > 2000
else Vector(scope.embedding_dimension),
)
distance = typed_embedding.cosine_distance(query_embedding) distance = typed_embedding.cosine_distance(query_embedding)
statement = ( statement = (
sqlalchemy.select( sqlalchemy.select(
+13 -26
View File
@@ -88,6 +88,7 @@ class ResolvedWorkspaceAccess:
@dataclasses.dataclass(frozen=True, slots=True) @dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceMemberView: class WorkspaceMemberView:
membership: WorkspaceMembership membership: WorkspaceMembership
display_name: str
email: str email: str
@@ -294,7 +295,7 @@ class WorkspaceCollaborationService:
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]: async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
await self._load_actor(active_session, workspace_uuid, actor) await self._load_actor(active_session, workspace_uuid, actor)
statement = ( statement = (
sqlalchemy.select(WorkspaceMembership, User.user) sqlalchemy.select(WorkspaceMembership, User.user, User.normalized_email)
.join(User, User.uuid == WorkspaceMembership.account_uuid) .join(User, User.uuid == WorkspaceMembership.account_uuid)
.where( .where(
WorkspaceMembership.workspace_uuid == workspace_uuid, WorkspaceMembership.workspace_uuid == workspace_uuid,
@@ -304,8 +305,12 @@ class WorkspaceCollaborationService:
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid) .order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
) )
return [ return [
WorkspaceMemberView(membership=membership, email=email) WorkspaceMemberView(
for membership, email in (await active_session.execute(statement)).all() membership=membership,
display_name=display_name,
email=email,
)
for membership, display_name, email in (await active_session.execute(statement)).all()
] ]
return await self._run(operation, session=session, read_only=True) return await self._run(operation, session=session, read_only=True)
@@ -606,6 +611,8 @@ class WorkspaceCollaborationService:
) -> WorkspaceMembership: ) -> WorkspaceMembership:
if role not in {item.value for item in MembershipRole}: if role not in {item.value for item in MembershipRole}:
raise MembershipPermissionError('Unknown Workspace role') raise MembershipPermissionError('Unknown Workspace role')
if role == MembershipRole.OWNER.value:
raise MembershipPermissionError('Workspace ownership cannot be transferred')
async def operation(active_session: AsyncSession) -> WorkspaceMembership: async def operation(active_session: AsyncSession) -> WorkspaceMembership:
await self._require_active_workspace(active_session, workspace_uuid) await self._require_active_workspace(active_session, workspace_uuid)
@@ -617,8 +624,8 @@ class WorkspaceCollaborationService:
target_account_uuid, target_account_uuid,
) )
self._require_can_manage_target(persisted_actor, target, new_role=role) self._require_can_manage_target(persisted_actor, target, new_role=role)
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value: if target.role == MembershipRole.OWNER.value:
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) raise LastOwnerError('The Workspace owner cannot be removed or demoted')
target.role = role target.role = role
await active_session.flush() await active_session.flush()
return target return target
@@ -644,7 +651,7 @@ class WorkspaceCollaborationService:
) )
self._require_can_manage_target(persisted_actor, target) self._require_can_manage_target(persisted_actor, target)
if target.role == MembershipRole.OWNER.value: if target.role == MembershipRole.OWNER.value:
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid) raise LastOwnerError('The Workspace owner cannot be removed or demoted')
target.status = MembershipStatus.REMOVED.value target.status = MembershipStatus.REMOVED.value
await active_session.flush() await active_session.flush()
return target return target
@@ -751,26 +758,6 @@ class WorkspaceCollaborationService:
raise WorkspaceNotFoundError('Workspace not found') raise WorkspaceNotFoundError('Workspace not found')
return persisted_actor 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: def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value: if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
raise WorkspaceNotFoundError('Workspace not found') raise WorkspaceNotFoundError('Workspace not found')
+2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import datetime
from dataclasses import dataclass from dataclasses import dataclass
@@ -12,3 +13,4 @@ class WorkspaceExecutionBinding:
placement_generation: int placement_generation: int
write_fenced: bool write_fenced: bool
state: str state: str
workspace_created_at: datetime.datetime | None = None
+1
View File
@@ -283,6 +283,7 @@ class WorkspaceService:
placement_generation=execution_state.active_generation, placement_generation=execution_state.active_generation,
write_fenced=execution_state.write_fenced, write_fenced=execution_state.write_fenced,
state=execution_state.state, state=execution_state.state,
workspace_created_at=workspace.created_at,
) )
binding = await self._run(operation, session=session) binding = await self._run(operation, session=session)
+1 -1
View File
@@ -201,7 +201,7 @@ vdb:
# keep this false when deliberately using an external pgvector DB. # keep this false when deliberately using an external pgvector DB.
use_business_database: false use_business_database: false
# Release migrations create one partial ANN index per enabled value. # Release migrations create one partial ANN index per enabled value.
allowed_dimensions: [384, 512, 768, 1024, 1536] allowed_dimensions: [384, 512, 768, 1024, 1536, 3072]
host: '127.0.0.1' host: '127.0.0.1'
port: 5433 port: 5433
database: 'langbot' database: 'langbot'
@@ -106,7 +106,12 @@ async def plugin_security_api(plugin_module):
application.plugin_connector.require_workspace_context = AsyncMock() application.plugin_connector.require_workspace_context = AsyncMock()
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin]) application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin) application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'}) application.plugin_connector.get_debug_info = AsyncMock(
return_value={
'plugin_debug_key': 'runtime-debug-secret',
'expires_at': '2026-08-04T12:00:00Z',
}
)
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line']) application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
application.plugin_connector.set_plugin_config = AsyncMock() application.plugin_connector.set_plugin_config = AsyncMock()
@@ -232,8 +237,9 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
assert (await allowed.get_json())['data'] == { assert (await allowed.get_json())['data'] == {
'debug_url': 'http://localhost:5401', 'debug_url': 'http://localhost:5401',
'plugin_debug_key': 'runtime-debug-secret', 'plugin_debug_key': 'runtime-debug-secret',
'expires_at': '2026-08-04T12:00:00Z',
} }
application.plugin_connector.get_debug_info.assert_awaited_once_with() application.plugin_connector.get_debug_info.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
+24
View File
@@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
import pytest import pytest
from unittest.mock import MagicMock, AsyncMock, Mock from unittest.mock import MagicMock, AsyncMock, Mock
@@ -304,12 +306,34 @@ class TestUserInitEndpoint:
data = await response.get_json() data = await response.get_json()
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'password_login_enabled': True, 'password_login_enabled': True,
'space_login_enabled': False, 'space_login_enabled': False,
} }
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited() 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 @pytest.mark.asyncio
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch): 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 fake_api_app.user_service.is_initialized.return_value = True
@@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
assert Permission.RESOURCE_MANAGE.value in permissions assert Permission.RESOURCE_MANAGE.value in permissions
assert not permissions.intersection( assert not permissions.intersection(
{ {
Permission.OWNER_TRANSFER.value,
Permission.MEMBER_VIEW.value, Permission.MEMBER_VIEW.value,
Permission.MEMBER_INVITE.value, Permission.MEMBER_INVITE.value,
Permission.MEMBER_UPDATE_ROLE.value, Permission.MEMBER_UPDATE_ROLE.value,
+40 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import datetime
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
@@ -14,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111' WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
WORKSPACE_CREATED_AT = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
@pytest.fixture @pytest.fixture
@@ -58,6 +60,12 @@ async def space_oauth_api():
return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID} return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
) )
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) 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( application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
) )
@@ -234,7 +242,11 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token' 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.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') application.space_service.exchange_oauth_code.assert_awaited_once_with(
'oauth-code',
[WORKSPACE_UUID],
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -272,9 +284,10 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
'/api/v1/user/space-credits', '/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
) )
payload = await response.get_json()
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data'] == { assert payload['data'] == {
'credits': 25000, 'credits': 25000,
'owner_space_bound': True, 'owner_space_bound': True,
'is_workspace_owner': True, 'is_workspace_owner': True,
@@ -282,6 +295,31 @@ 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') 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 @pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
+34
View File
@@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
workspace_uuid = current['workspace']['uuid'] workspace_uuid = current['workspace']['uuid']
assert current['membership']['role'] == 'owner' assert current['membership']['role'] == 'owner'
assert 'member.invite' in current['permissions'] assert 'member.invite' in current['permissions']
assert 'owner.transfer' not in current['permissions']
invite_response = await client.post( invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations', f'/api/v1/workspaces/{workspace_uuid}/invitations',
@@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert member_current['membership']['role'] == 'viewer' assert member_current['membership']['role'] == 'viewer'
assert 'member.invite' not in member_current['permissions'] 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( forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations', f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(member_token, workspace_uuid), headers=_auth(member_token, workspace_uuid),
@@ -272,6 +281,31 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert (await forbidden_invite.get_json())['code'] == 'permission_denied' 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): async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
_, client, _, owner_token = workspace_api _, client, _, owner_token = workspace_api
@@ -95,6 +95,18 @@ class TestSQLiteMigrationBaseline:
class TestSQLiteMigrationUpgrade: class TestSQLiteMigrationUpgrade:
"""Tests for upgrade to head workflow.""" """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() == '001a_pgvector_dimension_3072'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine): async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
""" """
@@ -85,6 +85,32 @@ async def clean_database(postgres_engine: AsyncEngine):
await clean() await clean()
async def test_upgrade_adds_3072_dimension_index_and_constraint(
postgres_engine: AsyncEngine,
clean_database,
) -> None:
async with postgres_engine.begin() as conn:
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(postgres_engine, '0010_scope_resources')
await run_alembic_upgrade(postgres_engine, 'head')
async with postgres_engine.connect() as conn:
constraint = await conn.scalar(
text(
'SELECT pg_get_constraintdef(oid) FROM pg_constraint '
"WHERE conrelid = 'langbot_vectors'::regclass "
"AND conname = 'ck_langbot_vectors_embedding_dimension_enabled'"
)
)
assert '3072' in constraint
index_definition = await conn.scalar(
text("SELECT indexdef FROM pg_indexes WHERE indexname = 'ix_langbot_vectors_hnsw_cosine_3072'")
)
assert 'halfvec(3072)' in index_definition
assert 'halfvec_cosine_ops' in index_definition
async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner( async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
postgres_url: str, postgres_url: str,
postgres_engine: AsyncEngine, postgres_engine: AsyncEngine,
@@ -92,7 +92,7 @@ def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_
'use': 'pgvector', 'use': 'pgvector',
'pgvector': { 'pgvector': {
'use_business_database': True, 'use_business_database': True,
'allowed_dimensions': [384, 512, 768, 1024, 1536], 'allowed_dimensions': [384, 512, 768, 1024, 1536, 3072],
}, },
}, },
} }
@@ -0,0 +1,100 @@
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()
+1 -2
View File
@@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission():
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission) assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing(): def test_admin_cannot_delete_workspace_or_link_billing():
ctx = _context(authz.WorkspaceRole.ADMIN) 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.WORKSPACE_DELETE)
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE) assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE) assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
@@ -0,0 +1,112 @@
"""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,6 +25,7 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
WORKSPACE_UUID = 'workspace-a' WORKSPACE_UUID = 'workspace-a'
SYSTEM_REQUESTER = 'space-chat-completions'
def _create_mock_provider( def _create_mock_provider(
@@ -1005,3 +1006,56 @@ class TestProviderSecretRoundtrip:
) )
ap.persistence_mgr.execute_async.assert_not_awaited() 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,6 +25,7 @@ import time
from langbot.pkg.api.http.service.space import SpaceService from langbot.pkg.api.http.service.space import SpaceService
from langbot.pkg.entity.persistence.user import User from langbot.pkg.entity.persistence.user import User
from langbot.pkg.utils import constants
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -573,10 +574,20 @@ class TestSpaceServiceExchangeOAuthCode:
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None) mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute # Execute
result = await service.exchange_oauth_code('auth_code') result = await service.exchange_oauth_code(
'auth_code',
['workspace-1'],
{'workspace-1': 1_700_000_000},
)
# Verify # Verify
assert result['access_token'] == 'new_access_token' 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): async def test_exchange_oauth_code_api_error(self):
"""Raises ValueError on API error.""" """Raises ValueError on API error."""
+20 -3
View File
@@ -66,6 +66,10 @@ class _Provider:
def __init__(self): def __init__(self):
self.manifest_provider = _Manifest() 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): def bootstrap(self, *, instance_uuid: str, instance_config: dict):
del instance_config del instance_config
return VerifiedCloudDeployment( return VerifiedCloudDeployment(
@@ -79,6 +83,7 @@ class _Provider:
entitlement_provider=_Entitlements(), entitlement_provider=_Entitlements(),
directory_provider=_Directory(), directory_provider=_Directory(),
manifest_provider=self.manifest_provider, manifest_provider=self.manifest_provider,
model_catalog_provider=self,
verification_key_id='root-2026', verification_key_id='root-2026',
) )
@@ -103,7 +108,7 @@ def _cloud_config() -> dict:
'use': 'pgvector', 'use': 'pgvector',
'pgvector': { 'pgvector': {
'use_business_database': True, 'use_business_database': True,
'allowed_dimensions': [384, 768, 1536], 'allowed_dimensions': [384, 768, 1536, 3072],
}, },
}, },
'mcp': {'stdio': {'enabled': False}}, 'mcp': {'stdio': {'enabled': False}},
@@ -211,7 +216,6 @@ async def test_cloud_directory_capacity_contract_is_fail_closed(directory_config
[ [
({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'), ({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'), ({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'), ({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
], ],
) )
@@ -228,10 +232,23 @@ 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( @pytest.mark.parametrize(
('mutate', 'message'), ('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'].update(backend='docker'), 'box.backend=nsjail'),
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'), (lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
( (
@@ -181,6 +181,39 @@ def _delta(
) )
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
application, _session_factory = projection_context
request_sync = Mock()
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
event = DirectoryEvent(
cursor=2,
uuid='20000000-0000-4000-8000-000000000002',
aggregate_uuid=WORKSPACE_UUID,
event_type='directory.changed',
revision=2,
payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
created_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
)
batch = DirectoryEventBatch(
instance_uuid=INSTANCE_UUID,
after_cursor=1,
cursor=2,
high_water_cursor=2,
events=[event],
)
service = DirectoryProjectionService(
application,
_Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
INSTANCE_UUID,
)
await service.initialize()
request_sync.reset_mock()
await service.sync_once()
request_sync.assert_called_once_with()
async def test_initial_snapshot_projects_core_owned_rows(projection_context): async def test_initial_snapshot_projects_core_owned_rows(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
reconcile_execution_projection = Mock() reconcile_execution_projection = Mock()
@@ -0,0 +1,466 @@
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 test_periodic_sync_discovers_workspace_created_after_startup_cache_release(tmp_path) -> None:
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog-new-workspace.db"}')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
startup_bindings = [
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1)
]
live_bindings = [
*startup_bindings,
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
]
class _WorkspaceService:
startup_released = False
async def list_active_execution_bindings(self):
return list(live_bindings if self.startup_released else startup_bindings)
def release_startup_execution_bindings(self):
self.startup_released = True
workspace_service = _WorkspaceService()
app = SimpleNamespace(
persistence_mgr=manager,
workspace_service=workspace_service,
model_mgr=SimpleNamespace(load_models_from_db=_AsyncCounter()),
logger=logging.getLogger(__name__),
)
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), 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 service.initialize()
workspace_service.release_startup_execution_bindings()
await service.sync_once()
async with engine.connect() as connection:
provider_b = await connection.scalar(
sqlalchemy.select(ModelProvider).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_B))
)
assert provider_b is not None
finally:
await engine.dispose()
async def test_catalog_run_wakes_immediately_when_directory_changes() -> None:
sync_started = asyncio.Event()
class _WakeService(CloudModelCatalogSyncService):
async def sync_once(self, *, reload_runtime: bool = True):
del reload_runtime
sync_started.set()
return {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
app = SimpleNamespace(logger=logging.getLogger(__name__))
service = _WakeService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID, sync_interval_seconds=3600)
task = asyncio.create_task(service.run())
try:
await asyncio.sleep(0)
service.request_sync()
await asyncio.wait_for(sync_started.wait(), timeout=0.2)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
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
@@ -7,7 +7,7 @@ from types import SimpleNamespace
import pytest import pytest
import sqlalchemy as sa import sqlalchemy as sa
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy.dialects.postgresql import insert as postgresql_insert from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
@@ -961,11 +961,13 @@ 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.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
sa.select( sa.select(
sa.func.now(), sa.func.now(),
sa.func.date_trunc('hour', sa.column('timestamp')),
sa.func.length(sa.literal('value')), sa.func.length(sa.literal('value')),
sa.func.nullif(sa.literal('value'), sa.literal('')), sa.func.nullif(sa.literal('value'), sa.literal('')),
), ),
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))), sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
sa.select(sa.cast(sa.column('embedding'), Vector(384))), sa.select(sa.cast(sa.column('embedding'), Vector(384))),
sa.select(sa.cast(sa.column('embedding'), HALFVEC(3072))),
sa.insert(sa.table('rows', sa.column('id'))).values(id=1), sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
_multi_value_statement(value=1), _multi_value_statement(value=1),
_on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))), _on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
from langbot.pkg.pipeline.controller import Controller from langbot.pkg.pipeline.controller import Controller
from langbot.pkg.pipeline.pool import QueryPool
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
@@ -143,3 +144,31 @@ async def test_controller_revalidates_generation_before_running_pipeline(
runtime_pipeline.run.assert_awaited_once_with(sample_query) runtime_pipeline.run.assert_awaited_once_with(sample_query)
query_pool.remove_query.assert_awaited_once_with(sample_query) query_pool.remove_query.assert_awaited_once_with(sample_query)
session._semaphore.release.assert_called_once_with() 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,6 +1,7 @@
"""Regression tests for isolated embed-widget conversations.""" """Regression tests for isolated embed-widget conversations."""
import asyncio import asyncio
import contextvars
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -204,6 +205,48 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}' 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 @pytest.mark.asyncio
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch): async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager() manager = WebSocketConnectionManager()
@@ -612,8 +612,13 @@ class TestDisabledPluginEarlyReturns:
mock_app.instance_config.data = {'plugin': {'enable': False}} mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect) connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
execution_context = connector_module.ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
result = await connector.get_debug_info() result = await connector.get_debug_info(execution_context)
assert result == {} assert result == {}
@@ -282,12 +282,11 @@ def test_closed_deployment_selects_instance_scoped_shared_profile():
assert connector.runtime_profile == 'shared' assert connector.runtime_profile == 'shared'
def test_external_runtime_control_headers_require_strong_secret(monkeypatch): def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False) monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector() connector = make_connector()
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV): assert connector._control_headers(allow_generate=False) == {}
connector._control_headers(allow_generate=False)
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch): def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
+24 -6
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -14,6 +15,12 @@ def get_heartbeat_module():
return import_module('langbot.pkg.telemetry.heartbeat') 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(): def make_app():
ap = Mock() ap = Mock()
ap.instance_config = Mock() ap.instance_config = Mock()
@@ -57,15 +64,17 @@ def make_app():
class TestBuildHeartbeatPayload: class TestBuildHeartbeatPayload:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_payload_shape(self): async def test_payload_shape(self, monkeypatch):
heartbeat = get_heartbeat_module() heartbeat = get_heartbeat_module()
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
ap = make_app() ap = make_app()
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a') payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
assert payload['event_type'] == 'instance_heartbeat' assert payload['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == '' assert payload['query_id'] == ''
assert payload['workspace_uuid'] == 'workspace-a' assert payload['workspace_uuid'] == 'workspace-a'
assert 'instance_id' not in payload assert payload['instance_id']
assert payload['workspace_create_ts'] == 0
assert 'instance_create_ts' in payload assert 'instance_create_ts' in payload
assert 'timestamp' in payload assert 'timestamp' in payload
f = payload['features'] f = payload['features']
@@ -100,8 +109,9 @@ class TestBuildHeartbeatPayload:
assert payload['features']['pipeline_count'] == -1 assert payload['features']['pipeline_count'] == -1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_counts_loaded_registries_without_tenant_sql(self): async def test_cloud_counts_loaded_registries_without_tenant_sql(self, monkeypatch):
heartbeat = get_heartbeat_module() heartbeat = get_heartbeat_module()
monkeypatch.setattr(heartbeat.constants, 'instance_id', 'instance-test')
ap = make_app() ap = make_app()
ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime') ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
ap.persistence_mgr.execute_async = AsyncMock( ap.persistence_mgr.execute_async = AsyncMock(
@@ -139,8 +149,12 @@ class TestBuildHeartbeatPayload:
} }
ap.workspace_service.list_active_execution_bindings = AsyncMock( ap.workspace_service.list_active_execution_bindings = AsyncMock(
return_value=[ return_value=[
SimpleNamespace(workspace_uuid='workspace-a'), SimpleNamespace(workspace_uuid='workspace-a', placement_generation=7),
SimpleNamespace(workspace_uuid='workspace-b'), SimpleNamespace(
workspace_uuid='workspace-b',
placement_generation=9,
workspace_created_at=datetime(2026, 8, 4, tzinfo=timezone.utc),
),
], ],
) )
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace( ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
@@ -150,7 +164,9 @@ class TestBuildHeartbeatPayload:
payloads = await heartbeat.build_heartbeat_payloads(ap) payloads = await heartbeat.build_heartbeat_payloads(ap)
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b'] assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
assert all('instance_id' not in payload for payload in payloads) assert all(payload['instance_id'] for payload in payloads)
assert payloads[0]['workspace_create_ts'] == 0
assert payloads[1]['workspace_create_ts'] == 1785801600
by_workspace = {payload['workspace_uuid']: payload['features'] 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']['pipeline_count'] == 2
assert by_workspace['workspace-a']['mcp_server_count'] == 3 assert by_workspace['workspace-a']['mcp_server_count'] == 3
@@ -159,10 +175,12 @@ class TestBuildHeartbeatPayload:
assert by_workspace['workspace-a']['plugin_count'] == 2 assert by_workspace['workspace-a']['plugin_count'] == 2
assert by_workspace['workspace-a']['extension_count'] == 5 assert by_workspace['workspace-a']['extension_count'] == 5
assert by_workspace['workspace-a']['skill_count'] == 2 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-a']['adapters'] == ['WorkspaceAAdapter']
assert by_workspace['workspace-b']['bot_count'] == 1 assert by_workspace['workspace-b']['bot_count'] == 1
assert by_workspace['workspace-b']['pipeline_count'] == 0 assert by_workspace['workspace-b']['pipeline_count'] == 0
assert by_workspace['workspace-b']['skill_count'] == 1 assert by_workspace['workspace-b']['skill_count'] == 1
assert by_workspace['workspace-b']['execution_generation'] == 9
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter'] assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
assert 'workspace_resources' not in by_workspace['workspace-a'] assert 'workspace_resources' not in by_workspace['workspace-a']
ap.persistence_mgr.execute_async.assert_not_awaited() ap.persistence_mgr.execute_async.assert_not_awaited()
@@ -596,6 +596,36 @@ class TestTelemetryManagedRuntimeAuthentication:
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'} 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: class TestStartSendTask:
"""Tests for start_send_task() method.""" """Tests for start_send_task() method."""
@@ -7,25 +7,28 @@ from types import SimpleNamespace
def test_standard_oss_instance_id_aligns_to_embedded_uuid(): def test_standard_oss_instance_id_aligns_to_embedded_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id 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(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(): def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
first = 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") second = workspace_uuid_from_instance_id('instance_migration_test')
assert first == second assert first == second
assert str(uuid.UUID(first)) == first assert str(uuid.UUID(first)) == first
def test_query_telemetry_identity_uses_execution_workspace_only(): def test_query_telemetry_identity_reports_instance_and_workspace():
from langbot.pkg.telemetry.identity import workspace_identity 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 == {"workspace_uuid": "workspace-a"} assert identity == {
'instance_id': 'instance-a',
'workspace_uuid': 'workspace-a',
}
+3 -3
View File
@@ -213,7 +213,7 @@ class TestVectorDBManagerInitialization:
mock_app, mock_app,
connection_string='postgresql://user:pass@host:5432/langbot', connection_string='postgresql://user:pass@host:5432/langbot',
use_business_database=False, use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536], allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
) )
def test_initialize_pgvector_with_individual_params(self): def test_initialize_pgvector_with_individual_params(self):
@@ -251,7 +251,7 @@ class TestVectorDBManagerInitialization:
user='admin', user='admin',
password='secret', password='secret',
use_business_database=False, use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536], allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
) )
def test_initialize_pgvector_defaults(self): def test_initialize_pgvector_defaults(self):
@@ -280,7 +280,7 @@ class TestVectorDBManagerInitialization:
user='postgres', user='postgres',
password='postgres', password='postgres',
use_business_database=False, use_business_database=False,
allowed_dimensions=[384, 512, 768, 1024, 1536], allowed_dimensions=[384, 512, 768, 1024, 1536, 3072],
) )
def test_initialize_pgvector_with_shared_business_database(self): def test_initialize_pgvector_with_shared_business_database(self):
@@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
second_membership, second_membership,
) )
promoted = await service.update_member_role( with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
workspace.uuid, await service.update_member_role(
second.uuid, workspace.uuid,
'owner', second.uuid,
owner_membership, 'owner',
) owner_membership,
assert promoted.role == 'owner' )
demoted = await service.update_member_role(
workspace.uuid, with pytest.raises(LastOwnerError):
owner_membership.account_uuid, await service.update_member_role(
'admin', workspace.uuid,
owner_membership, owner_membership.account_uuid,
) 'admin',
assert demoted.role == 'admin' owner_membership,
)
async def test_workspace_selector_requires_membership(collaboration_context): async def test_workspace_selector_requires_membership(collaboration_context):
Generated
+3705 -3678
View File
File diff suppressed because it is too large Load Diff
+52 -4
View File
@@ -5,6 +5,7 @@ import {
beginAuthenticatedSession, beginAuthenticatedSession,
beginSupportAdminSession, beginSupportAdminSession,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
clearPendingInvitationToken,
getPendingInvitationToken, getPendingInvitationToken,
} from '@/app/infra/http'; } from '@/app/infra/http';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -66,6 +67,10 @@ function SpaceOAuthCallbackContent() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const directLaunchFragmentRef = useRef<{
workspaceUuid: string | null;
launchAssertion: string | null;
} | null>(null);
const [status, setStatus] = useState< const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error' 'loading' | 'confirm' | 'success' | 'error'
@@ -108,8 +113,31 @@ function SpaceOAuthCallbackContent() {
} }
beginAuthenticatedSession(response.token, response.user); beginAuthenticatedSession(response.token, response.user);
if (getPendingInvitationToken()) { const invitationToken = getPendingInvitationToken();
navigate('/invitations/accept', { replace: true }); 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 });
return; return;
} }
const workspaceResult = await bootstrapWorkspaceSession({ const workspaceResult = await bootstrapWorkspaceSession({
@@ -220,8 +248,28 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description'); const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode'); const mode = searchParams.get('mode');
const state = searchParams.get('state'); const state = searchParams.get('state');
const workspaceUuid = searchParams.get('workspace_uuid'); if (directLaunchFragmentRef.current === null) {
const launchAssertion = searchParams.get('launch_assertion'); 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;
if (error) { if (error) {
setStatus('error'); setStatus('error');
+104 -42
View File
@@ -49,6 +49,8 @@ import type {
} from '@/app/home/mcp/components/mcp-form/MCPForm'; } from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel'; import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel'; 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'; type PopoverView = 'menu' | 'mcp' | 'github';
@@ -154,6 +156,12 @@ function AddExtensionContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); 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. // Localized label for an extension type, used in the install dialog.
const extensionTypeLabel = (type: string) => const extensionTypeLabel = (type: string) =>
@@ -344,23 +352,28 @@ function AddExtensionContent() {
t, t,
]); ]);
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { const handleInstallPlugin = useCallback(
setInstallInfo({ async (plugin: PluginV4) => {
plugin_author: plugin.author, if (extensionsReached) return;
plugin_name: plugin.name, setInstallInfo({
plugin_version: plugin.latest_version, plugin_author: plugin.author,
plugin_label: extractI18nObject(plugin.label) || plugin.name, plugin_name: plugin.name,
plugin_description: extractI18nObject(plugin.description) || '', plugin_version: plugin.latest_version,
plugin_icon: plugin.icon || '', plugin_label: extractI18nObject(plugin.label) || plugin.name,
}); plugin_description: extractI18nObject(plugin.description) || '',
setInstallExtensionType(plugin.type || 'plugin'); plugin_icon: plugin.icon || '',
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); });
setInstallError(null); setInstallExtensionType(plugin.type || 'plugin');
setInstallIconFailed(false); setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setModalOpen(true); setInstallError(null);
}, []); setInstallIconFailed(false);
setModalOpen(true);
},
[extensionsReached],
);
function handleModalConfirm() { function handleModalConfirm() {
if (extensionsReached) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING); setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`; const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient httpClient
@@ -402,6 +415,7 @@ function AddExtensionContent() {
const uploadFile = useCallback( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
if (extensionsReached) return;
if (!validateFileType(file)) { if (!validateFileType(file)) {
toast.error(t('addExtension.unsupportedFileType')); toast.error(t('addExtension.unsupportedFileType'));
return; return;
@@ -421,14 +435,15 @@ function AddExtensionContent() {
setSkillUploadPreviewOpen(true); setSkillUploadPreviewOpen(true);
} }
}, },
[t, setSelectedTaskId], [extensionsReached, t, setSelectedTaskId],
); );
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
if (extensionsReached) return;
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.click(); fileInputRef.current.click();
} }
}, []); }, [extensionsReached]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -455,12 +470,13 @@ function AddExtensionContent() {
(event: React.DragEvent) => { (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
setIsDragOver(false); setIsDragOver(false);
if (extensionsReached) return;
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
if (files.length > 0) { if (files.length > 0) {
uploadFile(files[0]); uploadFile(files[0]);
} }
}, },
[uploadFile], [extensionsReached, uploadFile],
); );
function handleMCPCreated(_serverName: string) { function handleMCPCreated(_serverName: string) {
@@ -490,7 +506,8 @@ function AddExtensionContent() {
return false; return false;
} }
} catch { } catch {
// If we can't check, let backend handle it toast.error(t('limitation.quotaCheckFailed'));
return false;
} }
return true; return true;
} }
@@ -630,9 +647,11 @@ function AddExtensionContent() {
async function handleGithubConfirm() { async function handleGithubConfirm() {
if (!selectedAsset || !selectedRelease) return; if (!selectedAsset || !selectedRelease) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.INSTALLING); setGithubInstallStatus(GithubInstallStatus.INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
return;
}
const pluginDisplayName = `${githubOwner}/${githubRepo}`; const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient httpClient
.installPluginFromGithub( .installPluginFromGithub(
@@ -664,9 +683,11 @@ function AddExtensionContent() {
async function handleGithubSkillConfirm() { async function handleGithubSkillConfirm() {
if (!githubSkillInfo) return; if (!githubSkillInfo) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING); setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
return;
}
try { try {
await httpClient.installSkillFromGithub( await httpClient.installSkillFromGithub(
githubURL.trim(), githubURL.trim(),
@@ -726,17 +747,24 @@ function AddExtensionContent() {
setPopoverOpen(open); setPopoverOpen(open);
}} }}
> >
<PopoverTrigger asChild> <WorkspaceQuotaTooltip
<Button quota={extensionQuota}
variant="default" resource={t('sidebar.extensions')}
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0" >
> <PopoverTrigger asChild>
<PlusIcon className="w-4 h-4" /> <Button
<span className="whitespace-nowrap"> variant="default"
{t('addExtension.manualAdd')} disabled={extensionsReached}
</span> aria-disabled={extensionsReached}
</Button> 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"
</PopoverTrigger> >
<PlusIcon className="w-4 h-4" />
<span className="whitespace-nowrap">
{t('addExtension.manualAdd')}
</span>
</Button>
</PopoverTrigger>
</WorkspaceQuotaTooltip>
<PopoverContent <PopoverContent
forceMount forceMount
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`} className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
@@ -745,9 +773,19 @@ function AddExtensionContent() {
{/* ===== Menu View ===== */} {/* ===== Menu View ===== */}
{popoverView === 'menu' && ( {popoverView === 'menu' && (
<div className="space-y-4 p-4"> <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 */} {/* File upload area */}
<div <div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${ aria-disabled={extensionsReached}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
extensionsReached
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
isDragOver isDragOver
? 'border-primary bg-primary/5' ? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50' : 'border-muted-foreground/25 hover:border-primary/50'
@@ -777,7 +815,8 @@ function AddExtensionContent() {
<div className="space-y-2"> <div className="space-y-2">
<button <button
type="button" type="button"
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={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"
onClick={() => setPopoverView('mcp')} 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"> <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -796,7 +835,8 @@ function AddExtensionContent() {
<button <button
type="button" type="button"
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={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"
onClick={() => setPopoverView('github')} 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"> <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -815,7 +855,8 @@ function AddExtensionContent() {
<button <button
type="button" type="button"
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={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"
onClick={async () => { onClick={async () => {
if (!(await checkExtensionsLimit())) return; if (!(await checkExtensionsLimit())) return;
setPopoverOpen(false); setPopoverOpen(false);
@@ -882,6 +923,7 @@ function AddExtensionContent() {
type="submit" type="submit"
form="mcp-form" form="mcp-form"
size="sm" size="sm"
disabled={extensionsReached}
onClick={async (e) => { onClick={async (e) => {
if (!(await checkExtensionsLimit())) { if (!(await checkExtensionsLimit())) {
e.preventDefault(); e.preventDefault();
@@ -946,6 +988,7 @@ function AddExtensionContent() {
className="w-full" className="w-full"
onClick={handleGithubAddressSubmit} onClick={handleGithubAddressSubmit}
disabled={ disabled={
extensionsReached ||
!githubURL.trim() || !githubURL.trim() ||
fetchingReleases || fetchingReleases ||
fetchingSkillPreview fetchingSkillPreview
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
</div> </div>
</div> </div>
)} )}
<Button className="w-full" onClick={handleGithubConfirm}> <Button
className="w-full"
onClick={handleGithubConfirm}
disabled={extensionsReached}
>
{t('common.confirm')} {t('common.confirm')}
</Button> </Button>
</div> </div>
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
<Button <Button
className="w-full" className="w-full"
onClick={handleGithubSkillConfirm} onClick={handleGithubSkillConfirm}
disabled={extensionsReached}
> >
{t('common.confirm')} {t('common.confirm')}
</Button> </Button>
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
<MarketPage <MarketPage
installPlugin={handleInstallPlugin} installPlugin={handleInstallPlugin}
headerActions={extensionActions} headerActions={extensionActions}
installDisabled={extensionsReached}
installDisabledTooltip={extensionQuotaTooltip}
/> />
</div> </div>
</div> </div>
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
<Button variant="outline" onClick={() => setModalOpen(false)}> <Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<Button onClick={handleModalConfirm}> <WorkspaceQuotaTooltip
{t('common.confirm')} quota={extensionQuota}
</Button> resource={t('sidebar.extensions')}
>
<Button
onClick={handleModalConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
</WorkspaceQuotaTooltip>
</> </>
)} )}
{pluginInstallStatus === PluginInstallStatus.ERROR && ( {pluginInstallStatus === PluginInstallStatus.ERROR && (
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
{pluginUploadPreviewFile && ( {pluginUploadPreviewFile && (
<PluginLocalPreviewPanel <PluginLocalPreviewPanel
file={pluginUploadPreviewFile} file={pluginUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => { onCancel={() => {
setPluginUploadPreviewOpen(false); setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null); setPluginUploadPreviewFile(null);
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
{skillUploadPreviewFile && ( {skillUploadPreviewFile && (
<SkillZipPreviewPanel <SkillZipPreviewPanel
file={skillUploadPreviewFile} file={skillUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => { onCancel={() => {
setSkillUploadPreviewOpen(false); setSkillUploadPreviewOpen(false);
setSkillUploadPreviewFile(null); setSkillUploadPreviewFile(null);
@@ -396,10 +396,9 @@ export default function BotForm({
<form <form
id="bot-form" id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)} onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
aria-busy={isLoading} aria-busy={isLoading}
> >
<fieldset className="contents" disabled={isLoading}> <fieldset className="space-y-6" disabled={isLoading}>
{/* Card 1: Basic Information */} {/* Card 1: Basic Information */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -8,6 +8,7 @@ import {
clearUserInfo, clearUserInfo,
getCloudServiceClientSync, getCloudServiceClientSync,
useCurrentWorkspace, useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http'; } from '@/app/infra/http';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -32,7 +33,6 @@ import {
Zap, Zap,
FilePlus2, FilePlus2,
Sparkles, Sparkles,
HardDrive,
Server, Server,
Puzzle, Puzzle,
RefreshCcw, RefreshCcw,
@@ -109,6 +109,11 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover'; 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 // Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean { function compareVersions(v1: string, v2: string): boolean {
@@ -279,6 +284,14 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); 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) { async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
@@ -386,6 +399,7 @@ function NavItems({
const pathname = location.pathname; const pathname = location.pathname;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const sidebarData = useSidebarData(); const sidebarData = useSidebarData();
const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar(); const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation(); const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace(); const currentWorkspace = useCurrentWorkspace();
@@ -529,7 +543,7 @@ function NavItems({
if (config.id === 'add-extension' && !canManageResources) { if (config.id === 'add-extension' && !canManageResources) {
return null; return null;
} }
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links // Non-entity entries (e.g. monitoring and the extension market) render as plain links.
return ( return (
<SidebarMenuItem key={config.id}> <SidebarMenuItem key={config.id}>
<SidebarMenuButton <SidebarMenuButton
@@ -575,6 +589,18 @@ function NavItems({
const isSkill = categoryId === 'skills'; const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots'; const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp'; 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 => { const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') { if (item.extensionType === 'mcp') {
@@ -907,128 +933,144 @@ function NavItems({
> >
<div className="flex items-center justify-between mb-1 px-2"> <div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span> <span className="text-sm font-medium">{config.name}</span>
{canCreate && {canCreate && (
(isPlugin ? ( <WorkspaceQuotaTooltip
<DropdownMenu> quota={quota}
<DropdownMenuTrigger asChild> resource={config.name}
<button side="right"
type="button" >
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" {isPlugin ? (
> <DropdownMenu>
<Plus className="size-3.5" /> <DropdownMenuTrigger asChild>
</button> <button
</DropdownMenuTrigger> type="button"
<DropdownMenuContent align="end"> disabled={quota.disabled}
{systemInfo.enable_marketplace && ( 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>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/home/add-extension'); navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
})); }));
}} }}
> >
<Store className="size-4" /> <Upload className="size-4" />
{t('plugins.goToMarketplace')} {t('plugins.uploadLocal')}
</DropdownMenuItem> </DropdownMenuItem>
)} <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigate('/home/add-extension?manual=1');
navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({
setPopoverOpen((prev) => ({ ...prev,
...prev, [config.id]: false,
[config.id]: false, }));
})); }}
}} >
> <Github className="size-4" />
<Upload className="size-4" /> {t('plugins.installFromGithub')}
{t('plugins.uploadLocal')} </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
<DropdownMenuItem </DropdownMenu>
onClick={(e) => { ) : isSkill ? (
e.stopPropagation(); <DropdownMenu>
navigate('/home/add-extension?manual=1'); <DropdownMenuTrigger asChild>
setPopoverOpen((prev) => ({ <button
...prev, type="button"
[config.id]: false, 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"
<Github className="size-4" /> >
{t('plugins.installFromGithub')} <Plus className="size-3.5" />
</DropdownMenuItem> </button>
</DropdownMenuContent> </DropdownMenuTrigger>
</DropdownMenu> <DropdownMenuContent align="end">
) : isSkill ? ( <DropdownMenuItem
<DropdownMenu> onClick={(e) => {
<DropdownMenuTrigger asChild> e.stopPropagation();
<button navigate('/home/skills?action=create');
type="button" setPopoverOpen((prev) => ({
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" ...prev,
> [config.id]: false,
<Plus className="size-3.5" /> }));
</button> }}
</DropdownMenuTrigger> >
<DropdownMenuContent align="end"> <FilePlus2 className="size-4" />
<DropdownMenuItem {t('skills.createManually')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); <DropdownMenuItem
navigate('/home/skills?action=create'); onClick={(e) => {
setPopoverOpen((prev) => ({ e.stopPropagation();
...prev, navigate('/home/add-extension?manual=1');
[config.id]: false, setPopoverOpen((prev) => ({
})); ...prev,
}} [config.id]: false,
> }));
<FilePlus2 className="size-4" /> }}
{t('skills.createManually')} >
</DropdownMenuItem> <Upload className="size-4" />
<DropdownMenuItem {t('skills.uploadZip')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); <DropdownMenuItem
navigate('/home/add-extension?manual=1'); onClick={(e) => {
setPopoverOpen((prev) => ({ e.stopPropagation();
...prev, navigate('/home/add-extension?manual=1');
[config.id]: false, setPopoverOpen((prev) => ({
})); ...prev,
}} [config.id]: false,
> }));
<Upload className="size-4" /> }}
{t('skills.uploadZip')} >
</DropdownMenuItem> <Github className="size-4" />
<DropdownMenuItem {t('skills.importFromGithub')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); </DropdownMenuContent>
navigate('/home/add-extension?manual=1'); </DropdownMenu>
setPopoverOpen((prev) => ({ ) : (
...prev, <button
[config.id]: false, type="button"
})); disabled={quota.disabled}
}} aria-disabled={quota.disabled}
> aria-label={`${t('common.create')} ${config.name}`}
<Github className="size-4" /> className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
{t('skills.importFromGithub')} onClick={() => {
</DropdownMenuItem> navigate(`${routePrefix}?id=new`);
</DropdownMenuContent> setPopoverOpen((prev) => ({
</DropdownMenu> ...prev,
) : ( [config.id]: false,
<button }));
type="button" }}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" >
onClick={() => { <Plus className="size-3.5" />
navigate(`${routePrefix}?id=new`); </button>
setPopoverOpen((prev) => ({ )}
...prev, </WorkspaceQuotaTooltip>
[config.id]: false, )}
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
</div> </div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto"> <div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)} {renderEntityList(true)}
@@ -1096,103 +1138,119 @@ function NavItems({
/> />
</button> </button>
)} )}
{canCreate && {canCreate && (
(isPlugin ? ( <WorkspaceQuotaTooltip
<DropdownMenu> quota={quota}
<DropdownMenuTrigger asChild> resource={config.name}
<button side="right"
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" {isPlugin ? (
onClick={(e) => e.stopPropagation()} <DropdownMenu>
> <DropdownMenuTrigger asChild>
<Plus className="size-3.5" /> <button
</button> type="button"
</DropdownMenuTrigger> disabled={quota.disabled}
<DropdownMenuContent align="end"> aria-disabled={quota.disabled}
{systemInfo.enable_marketplace && ( 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>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/home/add-extension'); navigate('/home/add-extension?manual=1');
}} }}
> >
<Store className="size-4" /> <Upload className="size-4" />
{t('plugins.goToMarketplace')} {t('plugins.uploadLocal')}
</DropdownMenuItem> </DropdownMenuItem>
)} <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigate('/home/add-extension?manual=1');
navigate('/home/add-extension?manual=1'); }}
}} >
> <Github className="size-4" />
<Upload className="size-4" /> {t('plugins.installFromGithub')}
{t('plugins.uploadLocal')} </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
<DropdownMenuItem </DropdownMenu>
onClick={(e) => { ) : isSkill ? (
e.stopPropagation(); <DropdownMenu>
navigate('/home/add-extension?manual=1'); <DropdownMenuTrigger asChild>
}} <button
> type="button"
<Github className="size-4" /> disabled={quota.disabled}
{t('plugins.installFromGithub')} aria-disabled={quota.disabled}
</DropdownMenuItem> aria-label={`${t('common.create')} ${config.name}`}
</DropdownMenuContent> 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"
</DropdownMenu> onClick={(e) => e.stopPropagation()}
) : isSkill ? ( >
<DropdownMenu> <Plus className="size-3.5" />
<DropdownMenuTrigger asChild> </button>
<button </DropdownMenuTrigger>
type="button" <DropdownMenuContent align="end">
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" <DropdownMenuItem
onClick={(e) => e.stopPropagation()} onClick={(e) => {
> e.stopPropagation();
<Plus className="size-3.5" /> navigate('/home/skills?action=create');
</button> }}
</DropdownMenuTrigger> >
<DropdownMenuContent align="end"> <FilePlus2 className="size-4" />
<DropdownMenuItem {t('skills.createManually')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); <DropdownMenuItem
navigate('/home/skills?action=create'); onClick={(e) => {
}} e.stopPropagation();
> navigate('/home/add-extension?manual=1');
<FilePlus2 className="size-4" /> }}
{t('skills.createManually')} >
</DropdownMenuItem> <Upload className="size-4" />
<DropdownMenuItem {t('skills.uploadZip')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); <DropdownMenuItem
navigate('/home/add-extension?manual=1'); onClick={(e) => {
}} e.stopPropagation();
> navigate('/home/add-extension?manual=1');
<Upload className="size-4" /> }}
{t('skills.uploadZip')} >
</DropdownMenuItem> <Github className="size-4" />
<DropdownMenuItem {t('skills.importFromGithub')}
onClick={(e) => { </DropdownMenuItem>
e.stopPropagation(); </DropdownMenuContent>
navigate('/home/add-extension?manual=1'); </DropdownMenu>
}} ) : (
> <button
<Github className="size-4" /> type="button"
{t('skills.importFromGithub')} disabled={quota.disabled}
</DropdownMenuItem> aria-disabled={quota.disabled}
</DropdownMenuContent> aria-label={`${t('common.create')} ${config.name}`}
</DropdownMenu> 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) => {
<button e.stopPropagation();
type="button" navigate(`${routePrefix}?id=new`);
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" />
navigate(`${routePrefix}?id=new`); </button>
}} )}
> </WorkspaceQuotaTooltip>
<Plus className="size-3.5" /> )}
</button>
))}
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="button" type="button"
@@ -1637,6 +1695,13 @@ export default function HomeSidebar({
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace(); 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 [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models'); useState<SettingsSection>('models');
@@ -1915,9 +1980,11 @@ export default function HomeSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<div className="px-2 group-data-[collapsible=icon]:px-0"> {showWorkspaceSwitcher && (
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" /> <div className="px-2 group-data-[collapsible=icon]:px-0">
</div> <WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
)}
{/* Navigation items grouped by section */} {/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -2098,15 +2165,16 @@ export default function HomeSidebar({
<UsersRound /> <UsersRound />
{t('workspace.settings')} {t('workspace.settings')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem {canViewStorageAnalysis && (
onClick={() => { <DropdownMenuItem
setUserMenuOpen(false); onClick={() => {
openSettings('storageAnalysis'); setUserMenuOpen(false);
}} openSettings('storageAnalysis');
> }}
<HardDrive /> >
{t('storageAnalysis.title')} {t('storageAnalysis.title')}
</DropdownMenuItem> </DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
@@ -4,6 +4,7 @@ import React, {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useRef,
} from 'react'; } from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http'; import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[]; pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[]; knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[]; plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[]; mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[]; skills: SidebarEntityItem[];
pluginPages: PluginPageItem[]; pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>; refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>; refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>; refreshKnowledgeBases: () => Promise<void>;
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]); const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]); const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]); const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]); const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]); 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 [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] = const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => { useState<boolean>(() => {
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
}, []); }, []);
const refreshBots = useCallback(async () => { const refreshBots = useCallback(async () => {
const requestId = ++refreshRequestIds.current.bots;
try { try {
const resp = await httpClient.getBots(); const resp = await httpClient.getBots();
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', true);
setBots( setBots(
resp.bots.map((bot) => ({ resp.bots.map((bot) => ({
id: bot.uuid || '', id: bot.uuid || '',
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
})), })),
); );
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', false);
console.error('Failed to fetch bots for sidebar:', error); console.error('Failed to fetch bots for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshPipelines = useCallback(async () => { const refreshPipelines = useCallback(async () => {
const requestId = ++refreshRequestIds.current.pipelines;
try { try {
const resp = await httpClient.getPipelines(); const resp = await httpClient.getPipelines();
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', true);
setPipelines( setPipelines(
resp.pipelines.map((p) => ({ resp.pipelines.map((p) => ({
id: p.uuid || '', id: p.uuid || '',
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
})), })),
); );
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', false);
console.error('Failed to fetch pipelines for sidebar:', error); console.error('Failed to fetch pipelines for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshKnowledgeBases = useCallback(async () => { const refreshKnowledgeBases = useCallback(async () => {
const requestId = ++refreshRequestIds.current.knowledgeBases;
try { try {
const resp = await httpClient.getKnowledgeBases(); const resp = await httpClient.getKnowledgeBases();
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', true);
setKnowledgeBases( setKnowledgeBases(
resp.bases.map((kb) => ({ resp.bases.map((kb) => ({
id: kb.uuid || '', id: kb.uuid || '',
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
})), })),
); );
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', false);
console.error('Failed to fetch knowledge bases for sidebar:', error); console.error('Failed to fetch knowledge bases for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshPlugins = useCallback(async () => { const refreshPlugins = useCallback(async () => {
const requestId = ++refreshRequestIds.current.plugins;
try { try {
const [pluginsResp, marketplaceResp] = await Promise.all([ const [pluginsResp, marketplaceResp] = await Promise.all([
httpClient.getPlugins(), httpClient.getPlugins(),
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
.getMarketplacePlugins(1, 100) .getMarketplacePlugins(1, 100)
.catch(() => ({ plugins: [] })), .catch(() => ({ plugins: [] })),
]); ]);
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', true);
setPluginCount(pluginsResp.plugins?.length ?? 0);
// Build marketplace version lookup: "author/name" -> latest_version // Build marketplace version lookup: "author/name" -> latest_version
const marketplaceVersions = new Map<string, string>(); const marketplaceVersions = new Map<string, string>();
@@ -241,13 +290,18 @@ export function SidebarDataProvider({
} }
setPluginPages(pages); setPluginPages(pages);
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', false);
console.error('Failed to fetch plugins for sidebar:', error); console.error('Failed to fetch plugins for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshMCPServers = useCallback(async () => { const refreshMCPServers = useCallback(async () => {
const requestId = ++refreshRequestIds.current.mcpServers;
try { try {
const resp = await httpClient.getMCPServers(); const resp = await httpClient.getMCPServers();
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', true);
setMCPServers( setMCPServers(
resp.servers.map((server) => ({ resp.servers.map((server) => ({
id: server.name, // Keep __ for API calls id: server.name, // Keep __ for API calls
@@ -257,13 +311,18 @@ export function SidebarDataProvider({
})), })),
); );
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', false);
console.error('Failed to fetch MCP servers for sidebar:', error); console.error('Failed to fetch MCP servers for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshSkills = useCallback(async () => { const refreshSkills = useCallback(async () => {
const requestId = ++refreshRequestIds.current.skills;
try { try {
const resp = await httpClient.getSkills(); const resp = await httpClient.getSkills();
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', true);
setSkills( setSkills(
resp.skills.map((skill) => ({ resp.skills.map((skill) => ({
id: skill.name, id: skill.name,
@@ -273,11 +332,22 @@ export function SidebarDataProvider({
})), })),
); );
} catch (error) { } catch (error) {
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', false);
console.error('Failed to fetch skills for sidebar:', error); console.error('Failed to fetch skills for sidebar:', error);
} }
}, []); }, [setQuotaResourceLoaded]);
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
quotaResourceLoaded.current = {
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
};
setQuotaDataLoaded(false);
await Promise.all([ await Promise.all([
refreshBots(), refreshBots(),
refreshPipelines(), refreshPipelines(),
@@ -307,9 +377,11 @@ export function SidebarDataProvider({
pipelines, pipelines,
knowledgeBases, knowledgeBases,
plugins, plugins,
pluginCount,
mcpServers, mcpServers,
skills, skills,
pluginPages, pluginPages,
quotaDataLoaded,
refreshBots, refreshBots,
refreshPipelines, refreshPipelines,
refreshKnowledgeBases, refreshKnowledgeBases,
@@ -218,20 +218,22 @@ export default function ProviderCard({
<span> <span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')} {(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span> </span>
<Button {isWorkspaceOwner && (
variant="ghost" <Button
size="icon" variant="ghost"
className="h-5 w-5" size="icon"
onClick={(e) => { className="h-5 w-5"
e.stopPropagation(); onClick={(e) => {
window.open( e.stopPropagation();
`${systemInfo.cloud_service_url}/profile?tab=billing`, window.open(
'_blank', `${systemInfo.cloud_service_url}/profile?tab=billing`,
); '_blank',
}} );
> }}
<Plus className="h-3 w-3" /> >
</Button> <Plus className="h-3 w-3" />
</Button>
)}
</div> </div>
)} )}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && ( {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
@@ -133,12 +133,14 @@ export default function SettingsDialog({
const permissions = currentWorkspace?.permissions ?? []; const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage'); const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view'); const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
const navItems = allNavItems.filter((item) => { const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') { if (item.id === 'apiIntegration') {
return canManageApiKeys; return canManageApiKeys;
} }
if (item.id === 'storageAnalysis') { if (item.id === 'storageAnalysis') {
return canViewAudit; return canViewStorageAnalysis;
} }
return true; return true;
}); });
@@ -146,11 +148,17 @@ export default function SettingsDialog({
useEffect(() => { useEffect(() => {
const forbiddenSection = const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) || (section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewAudit); (section === 'storageAnalysis' && !canViewStorageAnalysis);
if (open && forbiddenSection) { if (open && forbiddenSection) {
onSectionChange('workspace'); onSectionChange('workspace');
} }
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]); }, [
canManageApiKeys,
canViewStorageAnalysis,
open,
section,
onSectionChange,
]);
const activeItem = navItems.find((item) => item.id === section); const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title'); const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -256,7 +264,7 @@ export default function SettingsDialog({
active={open && section === 'apiIntegration'} active={open && section === 'apiIntegration'}
/> />
)} )}
{section === 'storageAnalysis' && ( {section === 'storageAnalysis' && canViewStorageAnalysis && (
<StorageAnalysisPanel <StorageAnalysisPanel
active={open && section === 'storageAnalysis'} active={open && section === 'storageAnalysis'}
/> />
@@ -0,0 +1,47 @@
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>
);
}
@@ -0,0 +1,82 @@
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,7 +79,6 @@ export default function WorkspaceSettingsPanel({
const canInvite = permissions.has('member.invite'); const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role'); const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove'); const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: ''; : '';
@@ -314,18 +313,20 @@ export default function WorkspaceSettingsPanel({
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<Users className="size-4" /> <Users className="size-4" />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent className="min-w-0">
<ItemTitle> <ItemTitle>
{member.email} {member.display_name}
{isSelf && ( {isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge> <Badge variant="outline">{t('workspace.you')}</Badge>
)} )}
</ItemTitle> </ItemTitle>
<ItemDescription> <ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
{t(`workspace.roles.${member.role}`)} <span className="break-all">{member.email}</span>
<span aria-hidden="true">·</span>
<span>{t(`workspace.roles.${member.role}`)}</span>
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
{canUpdateMembers && member.role !== 'owner' && ( {canUpdateMembers && member.role !== 'owner' && (
<Select <Select
value={member.role} value={member.role}
@@ -342,11 +343,6 @@ export default function WorkspaceSettingsPanel({
{t(`workspace.roles.${role}`)} {t(`workspace.roles.${role}`)}
</SelectItem> </SelectItem>
))} ))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
+28 -17
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/providers/theme-provider'; import { useTheme } from '@/components/providers/theme-provider';
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
/** /**
* Plugin page that renders a plugin-provided HTML page in an iframe. * Plugin page that renders a plugin-provided HTML page in an iframe.
@@ -80,11 +81,15 @@ function PluginPageIframe({
pageId: string; pageId: string;
}) { }) {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true); const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath); author,
pluginName,
pagePath,
);
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
// Send context (theme + language) to iframe // Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin // Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
@@ -170,23 +175,29 @@ function PluginPageIframe({
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
{loading && ( {assetError ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('plugins.loadFailed')}
</div>
) : loading || !assetUrl ? (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
Loading... Loading...
</div> </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> </div>
); );
} }
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; 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< type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal> ReturnType<typeof httpClient.previewPluginInstallFromLocal>
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
file: File; file: File;
onInstallStarted?: () => void; onInstallStarted?: () => void;
onCancel?: () => void; onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
file, file,
onInstallStarted, onInstallStarted,
onCancel, onCancel,
quota,
quotaResource = '',
}: PluginLocalPreviewPanelProps) { }: PluginLocalPreviewPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks(); const { addTask, setSelectedTaskId } = usePluginInstallTasks();
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
}, [loadPreview]); }, [loadPreview]);
async function handleInstall() { async function handleInstall() {
if (quota?.disabled) return;
setInstalling(true); setInstalling(true);
setErrorMessage(null); setErrorMessage(null);
try { try {
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
)} )}
<Button {quota ? (
type="button" <WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
onClick={handleInstall} <Button
disabled={!preview || previewing || installing} type="button"
> onClick={handleInstall}
{installing ? t('plugins.installing') : t('plugins.confirmInstall')} disabled={quota.disabled || !preview || previewing || installing}
</Button> >
{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>
)}
</div> </div>
</div> </div>
); );
@@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters {
function MarketPageContent({ function MarketPageContent({
installPlugin, installPlugin,
headerActions, headerActions,
installDisabled,
installDisabledTooltip,
}: { }: {
installPlugin: (plugin: PluginV4) => void; installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode; headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -847,6 +851,8 @@ function MarketPageContent({
lists={recommendationLists} lists={recommendationLists}
tagNames={tagNames} tagNames={tagNames}
onInstall={handleInstallPlugin} onInstall={handleInstallPlugin}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/> />
)} )}
@@ -876,6 +882,8 @@ function MarketPageContent({
cardVO={plugin} cardVO={plugin}
onInstall={handleInstallPlugin} onInstall={handleInstallPlugin}
tagNames={tagNames} tagNames={tagNames}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/> />
))} ))}
</div> </div>
@@ -915,9 +923,13 @@ function MarketPageContent({
export default function MarketPage({ export default function MarketPage({
installPlugin, installPlugin,
headerActions, headerActions,
installDisabled,
installDisabledTooltip,
}: { }: {
installPlugin: (plugin: PluginV4) => void; installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode; headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) { }) {
return ( return (
<Suspense <Suspense
@@ -932,6 +944,8 @@ export default function MarketPage({
<MarketPageContent <MarketPageContent
installPlugin={installPlugin} installPlugin={installPlugin}
headerActions={headerActions} headerActions={headerActions}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/> />
</Suspense> </Suspense>
); );
@@ -54,11 +54,15 @@ function RecommendationListRow({
list, list,
tagNames, tagNames,
onInstall, onInstall,
installDisabled,
installDisabledTooltip,
isLast, isLast,
}: { }: {
list: RecommendationList; list: RecommendationList;
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void; onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean; isLast: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -263,6 +267,8 @@ function RecommendationListRow({
cardVO={pluginToVO(plugin, t)} cardVO={pluginToVO(plugin, t)}
tagNames={tagNames} tagNames={tagNames}
onInstall={onInstall} onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/> />
))} ))}
</div> </div>
@@ -277,10 +283,14 @@ export function RecommendationLists({
lists, lists,
tagNames, tagNames,
onInstall, onInstall,
installDisabled,
installDisabledTooltip,
}: { }: {
lists: RecommendationList[]; lists: RecommendationList[];
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void; onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) { }) {
if (!lists || lists.length === 0) return null; if (!lists || lists.length === 0) return null;
@@ -292,6 +302,8 @@ export function RecommendationLists({
list={list} list={list}
tagNames={tagNames} tagNames={tagNames}
onInstall={onInstall} onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
isLast={index === lists.length - 1} isLast={index === lists.length - 1}
/> />
))} ))}
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
cardVO, cardVO,
onInstall, onInstall,
tagNames = {}, tagNames = {},
installDisabled = false,
installDisabledTooltip,
}: { }: {
cardVO: PluginMarketCardVO; cardVO: PluginMarketCardVO;
onInstall?: (cardVO: PluginMarketCardVO) => void; onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>; tagNames?: Record<string, string>;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0; const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
const handleInstallClick = () => { const handleInstallClick = () => {
if (installDisabled) return;
onInstall?.(cardVO); onInstall?.(cardVO);
}; };
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
} }
}; };
return ( const cardContent = (
<div <div
role="button" role={installDisabled ? 'group' : 'button'}
tabIndex={0} tabIndex={0}
aria-disabled={installDisabled}
aria-label={t('market.installCard', { name: cardVO.label })} aria-label={t('market.installCard', { name: cardVO.label })}
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" 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)]'
}`}
onClick={handleInstallClick} onClick={handleInstallClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if ( if (
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
</div> </div>
</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>
);
} }
+8
View File
@@ -57,6 +57,7 @@ function PluginListView() {
const [debugInfo, setDebugInfo] = useState<{ const [debugInfo, setDebugInfo] = useState<{
debug_url: string; debug_url: string;
plugin_debug_key: string; plugin_debug_key: string;
expires_at: string;
} | null>(null); } | null>(null);
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false); const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
@@ -275,6 +276,13 @@ function PluginListView() {
)} )}
</Button> </Button>
</div> </div>
{debugInfo?.expires_at && (
<p className="text-xs text-muted-foreground pl-[58px]">
{t('plugins.debugKeyExpires', {
time: new Date(debugInfo.expires_at).toLocaleString(),
})}
</p>
)}
{!debugInfo?.plugin_debug_key && ( {!debugInfo?.plugin_debug_key && (
<p className="text-xs text-muted-foreground ml-[58px]"> <p className="text-xs text-muted-foreground ml-[58px]">
{t('plugins.debugKeyDisabled')} {t('plugins.debugKeyDisabled')}
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api'; import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils'; 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 { interface PreviewSkill extends Skill {
source_path?: string; source_path?: string;
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
file: File; file: File;
onImported: (skillNames: string[]) => void; onImported: (skillNames: string[]) => void;
onCancel?: () => void; onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
file, file,
onImported, onImported,
onCancel, onCancel,
quota,
quotaResource = '',
}: SkillZipPreviewPanelProps) { }: SkillZipPreviewPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]); const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
} }
async function handleInstall() { async function handleInstall() {
if (quota?.disabled) return;
if (selectedPaths.length === 0) return; if (selectedPaths.length === 0) return;
setInstalling(true); setInstalling(true);
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
)} )}
<Button {quota ? (
type="button" <WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
onClick={handleInstall} <Button
disabled={ type="button"
previewing || onClick={handleInstall}
installing || disabled={
previewSkills.length === 0 || quota.disabled ||
selectedPaths.length === 0 previewing ||
} installing ||
> previewSkills.length === 0 ||
{installing ? ( selectedPaths.length === 0
<> }
<Loader2 className="size-4 animate-spin" /> >
{t('skills.installing')} {installing ? (
</> <>
) : ( <Loader2 className="size-4 animate-spin" />
<> {t('skills.installing')}
<PackageOpen className="size-4" /> </>
{t('skills.confirmInstall')} ) : (
</> <>
)} <PackageOpen className="size-4" />
</Button> {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>
)}
</div> </div>
</div> </div>
); );
+1
View File
@@ -328,6 +328,7 @@ export interface SystemLimitation {
max_bots: number; max_bots: number;
max_pipelines: number; max_pipelines: number;
max_extensions: number; max_extensions: number;
max_knowledge_bases?: number;
/** When non-empty, every pipeline is forced to this Box sandbox-scope /** When non-empty, every pipeline is forced to this Box sandbox-scope
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope" * template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
* selector is locked. Used by SaaS deployments. Empty = no restriction. */ * selector is locked. Used by SaaS deployments. Empty = no restriction. */
+1
View File
@@ -19,6 +19,7 @@ export interface WorkspaceMembership {
uuid: string; uuid: string;
workspace_uuid: string; workspace_uuid: string;
account_uuid: string; account_uuid: string;
display_name: string;
email: string; email: string;
role: WorkspaceRole; role: WorkspaceRole;
status: 'active' | 'disabled' | 'removed'; status: 'active' | 'disabled' | 'removed';
+2
View File
@@ -1091,6 +1091,7 @@ export class BackendClient extends BaseHttpClient {
public getPluginDebugInfo(): Promise<{ public getPluginDebugInfo(): Promise<{
debug_url: string; debug_url: string;
plugin_debug_key: string; plugin_debug_key: string;
expires_at: string;
}> { }> {
return this.get('/api/v1/plugins/debug-info'); return this.get('/api/v1/plugins/debug-info');
} }
@@ -1179,6 +1180,7 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{ public getAccountInfo(): Promise<{
initialized: boolean; initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean;
password_login_enabled?: boolean; password_login_enabled?: boolean;
space_login_enabled?: boolean; space_login_enabled?: boolean;
}> { }> {
+19 -1
View File
@@ -93,6 +93,10 @@ export default function AcceptInvitationPage() {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
useState(false); useState(false);
const [
authenticatedInvitationAcceptanceEnabled,
setAuthenticatedInvitationAcceptanceEnabled,
] = useState(false);
useEffect(() => { useEffect(() => {
const handleHashChange = () => setInvitationHash(window.location.hash); const handleHashChange = () => setInvitationHash(window.location.hash);
@@ -113,6 +117,9 @@ export default function AcceptInvitationPage() {
.getAccountInfo() .getAccountInfo()
.then((info) => { .then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false); setPasswordRegistrationEnabled(info.password_login_enabled !== false);
setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true,
);
}) })
.catch(() => setPasswordRegistrationEnabled(false)); .catch(() => setPasswordRegistrationEnabled(false));
if (!invitationToken) { if (!invitationToken) {
@@ -304,7 +311,18 @@ export default function AcceptInvitationPage() {
</div> </div>
)} )}
{hasLoginToken ? ( {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 ? (
<div className="space-y-3"> <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"> <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')} {t('workspace.authenticatedInvitationNotice')}
+1 -1
View File
@@ -119,7 +119,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 text-balance', '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',
className, className,
)} )}
{...props} {...props}
+52 -20
View File
@@ -1,41 +1,63 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; 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( export function useAuthenticatedPluginIcon(
author: string, author: string,
name: string, name: string,
enabled = true, enabled = true,
): { url: string; error: boolean } { ): { url: string; error: boolean } {
const [url, setURL] = useState(''); const [resource, setResource] =
const [error, setError] = useState(false); useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
setURL(''); setResource({ key: resourceKey, url: '', error: false });
setError(false);
return; return;
} }
let active = true; let active = true;
let objectURL = ''; let objectURL = '';
setURL(''); setResource({ key: resourceKey, url: '', error: false });
setError(false);
httpClient httpClient
.getAuthenticatedPluginIconURL(author, name) .getAuthenticatedPluginIconURL(author, name)
.then((nextURL) => { .then((nextURL) => {
objectURL = nextURL; objectURL = nextURL;
if (active) setURL(nextURL); if (active) {
else URL.revokeObjectURL(nextURL); setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
}) })
.catch(() => { .catch(() => {
if (active) setError(true); if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
}); });
return () => { return () => {
active = false; active = false;
if (objectURL) URL.revokeObjectURL(objectURL); if (objectURL) URL.revokeObjectURL(objectURL);
}; };
}, [author, enabled, name]); }, [author, enabled, name, resourceKey]);
return { url, error }; return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
} }
export function useAuthenticatedPluginAsset( export function useAuthenticatedPluginAsset(
@@ -43,29 +65,39 @@ export function useAuthenticatedPluginAsset(
name: string, name: string,
filepath: string, filepath: string,
): { url: string; error: boolean } { ): { url: string; error: boolean } {
const [url, setURL] = useState(''); const [resource, setResource] =
const [error, setError] = useState(false); useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
useEffect(() => { useEffect(() => {
let active = true; let active = true;
let objectURL = ''; let objectURL = '';
setURL(''); setResource({ key: resourceKey, url: '', error: false });
setError(false);
httpClient httpClient
.getAuthenticatedPluginAssetURL(author, name, filepath) .getAuthenticatedPluginAssetURL(author, name, filepath)
.then((nextURL) => { .then((nextURL) => {
objectURL = nextURL; objectURL = nextURL;
if (active) setURL(nextURL); if (active) {
else URL.revokeObjectURL(nextURL); setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
}) })
.catch(() => { .catch(() => {
if (active) setError(true); if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
}); });
return () => { return () => {
active = false; active = false;
if (objectURL) URL.revokeObjectURL(objectURL); if (objectURL) URL.revokeObjectURL(objectURL);
}; };
}, [author, name, filepath]); }, [author, name, filepath, resourceKey]);
return { url, error }; return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
} }
+8 -2
View File
@@ -521,9 +521,9 @@ const enUS = {
debugInfoTitle: 'Plugin Debug Information', debugInfoTitle: 'Plugin Debug Information',
debugUrl: 'Debug URL', debugUrl: 'Debug URL',
debugKey: 'Debug Key', debugKey: 'Debug Key',
debugKeyExpires: 'Rotates at {{time}}; each Workspace has a different key',
noDebugKey: '(Not Set)', noDebugKey: '(Not Set)',
debugKeyDisabled: debugKeyDisabled: 'Debug credential is temporarily unavailable',
'Debug key is not set, plugin debugging does not require authentication',
boxStatusTitle: 'Box Runtime', boxStatusTitle: 'Box Runtime',
boxStatus: 'Status', boxStatus: 'Status',
boxConnected: 'Connected', boxConnected: 'Connected',
@@ -1678,6 +1678,12 @@ const enUS = {
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.', 'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
maxExtensionsReached: maxExtensionsReached:
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.', '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: { skills: {
title: 'Skills', title: 'Skills',
+9 -1
View File
@@ -535,9 +535,11 @@ const esES = {
debugInfoTitle: 'Información de depuración del plugin', debugInfoTitle: 'Información de depuración del plugin',
debugUrl: 'URL de depuración', debugUrl: 'URL de depuración',
debugKey: 'Clave de depuración', debugKey: 'Clave de depuración',
debugKeyExpires:
'Rota a las {{time}}; cada Workspace tiene una clave distinta',
noDebugKey: '(No establecida)', noDebugKey: '(No establecida)',
debugKeyDisabled: debugKeyDisabled:
'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación', 'La credencial de depuración no está disponible temporalmente',
boxStatusTitle: 'Box Runtime', boxStatusTitle: 'Box Runtime',
boxStatus: 'Estado', boxStatus: 'Estado',
boxConnected: 'Conectado', boxConnected: 'Conectado',
@@ -1634,6 +1636,12 @@ const esES = {
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.', 'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
maxExtensionsReached: 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.', '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: { wizard: {
sidebarDescription: 'Crea un Bot con pasos guiados', sidebarDescription: 'Crea un Bot con pasos guiados',
+9 -2
View File
@@ -527,9 +527,10 @@ const jaJP = {
debugInfoTitle: 'プラグインデバッグ情報', debugInfoTitle: 'プラグインデバッグ情報',
debugUrl: 'デバッグURL', debugUrl: 'デバッグURL',
debugKey: 'デバッグキー', debugKey: 'デバッグキー',
debugKeyExpires:
'{{time}} にローテーションします。Workspace ごとにキーが異なります',
noDebugKey: '(未設定)', noDebugKey: '(未設定)',
debugKeyDisabled: debugKeyDisabled: 'デバッグ認証情報を一時的に利用できません',
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
boxStatusTitle: 'Box ランタイム', boxStatusTitle: 'Box ランタイム',
boxStatus: 'ステータス', boxStatus: 'ステータス',
boxConnected: '接続済み', boxConnected: '接続済み',
@@ -1685,6 +1686,12 @@ const jaJP = {
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。', 'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
maxExtensionsReached: maxExtensionsReached:
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。', '拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
quotaLoadingTooltip:
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
quotaCheckFailed:
'現在のワークスペース上限を確認できません。もう一度お試しください。',
createDisabledTooltip:
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
}, },
wizard: { wizard: {
sidebarDescription: 'ガイド付きステップでボットを作成', sidebarDescription: 'ガイド付きステップでボットを作成',
+8 -2
View File
@@ -534,9 +534,9 @@ const ruRU = {
debugInfoTitle: 'Отладочная информация плагина', debugInfoTitle: 'Отладочная информация плагина',
debugUrl: 'URL для отладки', debugUrl: 'URL для отладки',
debugKey: 'Ключ отладки', debugKey: 'Ключ отладки',
debugKeyExpires: 'Смена в {{time}}; у каждого Workspace свой ключ',
noDebugKey: '(Не задан)', noDebugKey: '(Не задан)',
debugKeyDisabled: debugKeyDisabled: 'Учетные данные отладки временно недоступны',
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
boxStatusTitle: 'Box Runtime', boxStatusTitle: 'Box Runtime',
boxStatus: 'Статус', boxStatus: 'Статус',
boxConnected: 'Подключено', boxConnected: 'Подключено',
@@ -1609,6 +1609,12 @@ const ruRU = {
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.', 'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
maxExtensionsReached: maxExtensionsReached:
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.', 'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
quotaLoadingTooltip:
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
quotaCheckFailed:
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
createDisabledTooltip:
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
}, },
wizard: { wizard: {
sidebarDescription: 'Создать бота с пошаговым руководством', sidebarDescription: 'Создать бота с пошаговым руководством',
+8 -2
View File
@@ -518,9 +518,9 @@ const thTH = {
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน', debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
debugUrl: 'URL ดีบัก', debugUrl: 'URL ดีบัก',
debugKey: 'คีย์ดีบัก', debugKey: 'คีย์ดีบัก',
debugKeyExpires: 'หมุนเวียนเวลา {{time}}; แต่ละ Workspace ใช้คีย์ต่างกัน',
noDebugKey: '(ไม่ได้ตั้งค่า)', noDebugKey: '(ไม่ได้ตั้งค่า)',
debugKeyDisabled: debugKeyDisabled: 'ข้อมูลรับรองการดีบักไม่พร้อมใช้งานชั่วคราว',
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
boxStatusTitle: 'Box Runtime', boxStatusTitle: 'Box Runtime',
boxStatus: 'สถานะ', boxStatus: 'สถานะ',
boxConnected: 'เชื่อมต่อแล้ว', boxConnected: 'เชื่อมต่อแล้ว',
@@ -1576,6 +1576,12 @@ const thTH = {
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่', 'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
maxExtensionsReached: maxExtensionsReached:
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่', 'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
quotaLoadingTooltip:
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
quotaCheckFailed:
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
createDisabledTooltip:
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
}, },
wizard: { wizard: {
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ', sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
+8 -2
View File
@@ -529,9 +529,9 @@ const viVN = {
debugInfoTitle: 'Thông tin gỡ lỗi Plugin', debugInfoTitle: 'Thông tin gỡ lỗi Plugin',
debugUrl: 'URL gỡ lỗi', debugUrl: 'URL gỡ lỗi',
debugKey: 'Khóa gỡ lỗi', debugKey: 'Khóa gỡ lỗi',
debugKeyExpires: 'Xoay vòng lúc {{time}}; mỗi Workspace có khóa riêng',
noDebugKey: '(Chưa đặt)', noDebugKey: '(Chưa đặt)',
debugKeyDisabled: debugKeyDisabled: 'Thông tin xác thực gỡ lỗi tạm thời không khả dụng',
'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực',
boxStatusTitle: 'Box Runtime', boxStatusTitle: 'Box Runtime',
boxStatus: 'Trạng thái', boxStatus: 'Trạng thái',
boxConnected: 'Đã kết nối', boxConnected: 'Đã kết nối',
@@ -1602,6 +1602,12 @@ 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.', 'Đã đạ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: 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.', 'Đã đạ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: { wizard: {
sidebarDescription: 'Tạo Bot với các bước hướng dẫn', sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
+6 -1
View File
@@ -496,8 +496,9 @@ const zhHans = {
debugInfoTitle: '插件调试信息', debugInfoTitle: '插件调试信息',
debugUrl: '调试地址', debugUrl: '调试地址',
debugKey: '调试密钥', debugKey: '调试密钥',
debugKeyExpires: '将于 {{time}} 轮换;每个工作区的密钥不同',
noDebugKey: '(未设置)', noDebugKey: '(未设置)',
debugKeyDisabled: '未设置调试密钥,插件调试无需认证', debugKeyDisabled: '调试凭据暂不可用',
boxStatusTitle: 'Box 运行时', boxStatusTitle: 'Box 运行时',
boxStatus: '状态', boxStatus: '状态',
boxConnected: '已连接', boxConnected: '已连接',
@@ -1606,6 +1607,10 @@ const zhHans = {
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。', '已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
maxExtensionsReached: maxExtensionsReached:
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。', '已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
createDisabledTooltip:
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
}, },
skills: { skills: {
title: '技能', title: '技能',
+6 -1
View File
@@ -501,8 +501,9 @@ const zhHant = {
debugInfoTitle: '外掛偵錯資訊', debugInfoTitle: '外掛偵錯資訊',
debugUrl: '偵錯位址', debugUrl: '偵錯位址',
debugKey: '偵錯金鑰', debugKey: '偵錯金鑰',
debugKeyExpires: '將於 {{time}} 輪換;每個工作區的密鑰不同',
noDebugKey: '(未設定)', noDebugKey: '(未設定)',
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證', debugKeyDisabled: '偵錯憑據暫時無法使用',
boxStatusTitle: 'Box 執行時', boxStatusTitle: 'Box 執行時',
boxStatus: '狀態', boxStatus: '狀態',
boxConnected: '已連線', boxConnected: '已連線',
@@ -1531,6 +1532,10 @@ const zhHant = {
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。', '已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
maxExtensionsReached: maxExtensionsReached:
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。', '已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
createDisabledTooltip:
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
}, },
wizard: { wizard: {
sidebarDescription: '透過引導步驟建立機器人', sidebarDescription: '透過引導步驟建立機器人',
@@ -0,0 +1,63 @@
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);
});
+2 -1
View File
@@ -81,6 +81,7 @@ export interface WorkspaceEntryMock {
uuid: string; uuid: string;
workspace_uuid: string; workspace_uuid: string;
account_uuid: string; account_uuid: string;
display_name: string;
email: string; email: string;
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer'; role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
status: 'active'; status: 'active';
@@ -155,6 +156,7 @@ export function makeWorkspaceEntry(
uuid: `membership-${uuid}`, uuid: `membership-${uuid}`,
workspace_uuid: uuid, workspace_uuid: uuid,
account_uuid: 'account-playwright', account_uuid: 'account-playwright',
display_name: 'Playwright Admin',
email: 'admin@example.com', email: 'admin@example.com',
role: 'owner', role: 'owner',
status: 'active', status: 'active',
@@ -169,7 +171,6 @@ export function makeWorkspaceEntry(
'member.remove', 'member.remove',
'member.update_role', 'member.update_role',
'member.view', 'member.view',
'owner.transfer',
'provider_secret.manage', 'provider_secret.manage',
'resource.manage', 'resource.manage',
'resource.view', 'resource.view',

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