mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 04:10:59 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61faa68194 | |||
| cf361fd736 | |||
| 289d19d350 | |||
| c34f07b186 |
@@ -1,59 +0,0 @@
|
|||||||
name: Build and deploy production
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [deploy/prod]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: langbot-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
|
||||||
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
|
||||||
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build exact Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CORE_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=core-prod
|
|
||||||
- name: Checkout production Cloud adapter
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: langbot-app/langbot-space
|
|
||||||
ref: ${{ env.SPACE_REF }}
|
|
||||||
token: ${{ secrets.CLA_PAT }}
|
|
||||||
path: .space
|
|
||||||
- name: Build exact Cloud Core image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .space
|
|
||||||
file: .space/Dockerfile.cloud
|
|
||||||
push: true
|
|
||||||
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
tags: |
|
|
||||||
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
|
|
||||||
${{ env.CLOUD_IMAGE }}:deploy-prod
|
|
||||||
cache-from: type=gha,scope=cloud-core-prod
|
|
||||||
cache-to: type=gha,mode=max,scope=cloud-core-prod
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
cd /opt/langbot-cloud-prod
|
|
||||||
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
|
|
||||||
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
|
|
||||||
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
|
|
||||||
|
|
||||||
rendered_compose=$(docker compose config)
|
|
||||||
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
|
|
||||||
exit 4
|
|
||||||
}
|
|
||||||
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
|
|
||||||
exit 5
|
|
||||||
}
|
|
||||||
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
|
|
||||||
echo 'Cloud telemetry ingest token must be configured' >&2
|
|
||||||
exit 6
|
|
||||||
}
|
|
||||||
|
|
||||||
update_env() {
|
|
||||||
local key=$1 value=$2
|
|
||||||
python3 - "$key" "$value" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = Path('.env')
|
|
||||||
key, value = sys.argv[1:]
|
|
||||||
lines = path.read_text().splitlines()
|
|
||||||
updated = False
|
|
||||||
for index, line in enumerate(lines):
|
|
||||||
if line.startswith(f'{key}='):
|
|
||||||
lines[index] = f'{key}={value}'
|
|
||||||
updated = True
|
|
||||||
break
|
|
||||||
if not updated:
|
|
||||||
lines.append(f'{key}={value}')
|
|
||||||
temporary = Path('.env.tmp')
|
|
||||||
temporary.write_text('\n'.join(lines) + '\n')
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
temporary.replace(path)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
update_env LANGBOT_IMAGE_TAG "$TAG"
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
|
|
||||||
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if docker compose pull postgres redis migrate plugin-runtime core; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -eq 5 ]; then
|
|
||||||
echo "docker compose pull failed after $attempt attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
delay=$((attempt * 10))
|
|
||||||
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
|
|
||||||
sleep "$delay"
|
|
||||||
done
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
|
|
||||||
|
|
||||||
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
|
|
||||||
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
|
|
||||||
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
|
|
||||||
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
|
|
||||||
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
|
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
|
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
|
|
||||||
GRANT USAGE ON SCHEMA public TO langbot_runtime;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
docker compose --profile tools run --rm migrate
|
|
||||||
|
|
||||||
docker compose up -d --remove-orphans plugin-runtime core
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
|
|
||||||
docker compose ps
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
docker compose logs --tail=200 core plugin-runtime >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg17
|
|
||||||
container_name: langbot-cloud-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: langbot
|
|
||||||
POSTGRES_USER: langbot_operator
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7.4-alpine
|
|
||||||
container_name: langbot-cloud-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
environment:
|
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
profiles: [tools]
|
|
||||||
command: [uv, run, langbot, migrate, --cloud]
|
|
||||||
environment: &core-env
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
|
|
||||||
SYSTEM__EDITION: cloud
|
|
||||||
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
|
|
||||||
SYSTEM__JWT__SECRET: ${JWT_SECRET}
|
|
||||||
SYSTEM__LIMITATION__MAX_BOTS: "2"
|
|
||||||
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
|
|
||||||
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
|
|
||||||
API__WEBHOOK_PREFIX: https://cloud.langbot.app
|
|
||||||
API__WEBUI_URL: https://cloud.langbot.app
|
|
||||||
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
|
|
||||||
DATABASE__USE: postgresql
|
|
||||||
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
|
|
||||||
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
|
|
||||||
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
|
|
||||||
VDB__USE: pgvector
|
|
||||||
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
|
|
||||||
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
|
|
||||||
PLUGIN__ENABLE: "true"
|
|
||||||
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
|
|
||||||
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
|
|
||||||
PLUGIN__WORKER__MAX_CPUS: "0.25"
|
|
||||||
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
|
|
||||||
PLUGIN__WORKER__MAX_PIDS: "128"
|
|
||||||
PLUGIN__WORKER__MAX_WORKERS: "16"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
|
|
||||||
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
|
|
||||||
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
# Cloud v2 currently grants no managed Box capability. Keep the shared
|
|
||||||
# runtime deployed but disable Core integration until a hard-quota-capable
|
|
||||||
# backend can satisfy the fail-closed Cloud readiness contract.
|
|
||||||
BOX__ENABLED: "false"
|
|
||||||
BOX__BACKEND: nsjail
|
|
||||||
BOX__RUNTIME__ENDPOINT: ws://box:5410
|
|
||||||
BOX__ADMISSION__REQUIRED: "true"
|
|
||||||
BOX__ADMISSION__LOGICAL_SESSION_ID: global
|
|
||||||
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
|
|
||||||
BOX__ADMISSION__MAX_SESSIONS: "1"
|
|
||||||
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
|
|
||||||
BOX__ADMISSION__CPUS: "0.25"
|
|
||||||
BOX__ADMISSION__MEMORY_MB: "256"
|
|
||||||
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
|
|
||||||
BOX__LOCAL__HOST_ROOT: /app/data/box
|
|
||||||
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
|
|
||||||
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
MCP__STDIO__ENABLED: "false"
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
|
|
||||||
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
|
|
||||||
SPACE__URL: https://space.langbot.app
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
plugin-runtime:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-plugin-runtime
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
|
|
||||||
environment:
|
|
||||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
|
||||||
volumes:
|
|
||||||
- plugin-data:/app/data
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5400"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
box:
|
|
||||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-box
|
|
||||||
restart: unless-stopped
|
|
||||||
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
|
|
||||||
environment:
|
|
||||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
|
||||||
LANGBOT_BOX_ROOT: /app/data/box
|
|
||||||
volumes:
|
|
||||||
- box-data:/app/data/box
|
|
||||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
|
||||||
cgroup: host
|
|
||||||
privileged: true
|
|
||||||
expose: ["5410"]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
core:
|
|
||||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
|
||||||
container_name: langbot-cloud-core
|
|
||||||
restart: unless-stopped
|
|
||||||
environment: *core-env
|
|
||||||
volumes:
|
|
||||||
- core-data:/app/data
|
|
||||||
- box-data:/app/data/box
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
redis: {condition: service_healthy}
|
|
||||||
plugin-runtime: {condition: service_started}
|
|
||||||
box: {condition: service_started}
|
|
||||||
expose: ["5300"]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
networks: [internal, shared-network]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
internal:
|
|
||||||
shared-network:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
redis-data:
|
|
||||||
plugin-data:
|
|
||||||
box-data:
|
|
||||||
core-data:
|
|
||||||
@@ -14,8 +14,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or set the same value on
|
# Shared with the langbot service and sent only as a WebSocket handshake
|
||||||
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
# header. 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,7 +77,8 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
# Must match langbot_plugin_runtime. Empty/missing values make the
|
||||||
|
# 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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.6"
|
||||||
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@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
|
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
|
||||||
"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",
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ 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.
|
||||||
|
|
||||||
A Compose deployment may optionally set
|
Every Compose deployment also needs one
|
||||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
||||||
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
||||||
defaults to leaving it unset on both sides. If enabled, generate one value with
|
before `docker compose up`; the external Plugin Runtime fails closed when the
|
||||||
`openssl rand -hex 32`; configuring only one side causes the control connection
|
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
||||||
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
Secret shown in `docker/kubernetes.yaml`.
|
||||||
`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`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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'
|
||||||
@@ -48,6 +49,7 @@ _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,6 +62,7 @@ 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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import posixpath
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
|
from .....core.task_boundary import run_in_workspace_uow
|
||||||
from .....entity.persistence import plugin as persistence_plugin
|
from .....entity.persistence import plugin as persistence_plugin
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
from ...context import ExecutionContext, RequestContext
|
from ...context import ExecutionContext, RequestContext
|
||||||
@@ -310,13 +311,11 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
):
|
):
|
||||||
"""Revalidate a captured task context immediately before Runtime I/O."""
|
"""Revalidate a captured task context immediately before Runtime I/O."""
|
||||||
|
|
||||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
await run_in_workspace_uow(
|
||||||
tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
|
self.ap,
|
||||||
if callable(tenant_scope):
|
execution_context.workspace_uuid,
|
||||||
async with tenant_scope(execution_context.workspace_uuid):
|
lambda: self.ap.plugin_connector.require_workspace_context(execution_context),
|
||||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
)
|
||||||
return await operation()
|
|
||||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
|
||||||
return await operation()
|
return await operation()
|
||||||
|
|
||||||
async def _require_authenticated_plugin_runtime_context(
|
async def _require_authenticated_plugin_runtime_context(
|
||||||
@@ -393,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"""
|
||||||
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
|
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||||
|
|
||||||
# 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', {})
|
||||||
@@ -404,7 +403,6 @@ 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,7 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -219,22 +218,7 @@ 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
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
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)
|
||||||
@@ -247,6 +231,7 @@ 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(
|
||||||
@@ -300,25 +285,8 @@ 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)
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
owner_space_bound = bool(owner and owner.space_account_uuid)
|
||||||
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
|
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
||||||
# 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,
|
||||||
@@ -334,10 +302,8 @@ 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()
|
||||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
if 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,14 +30,12 @@ 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,
|
||||||
@@ -96,11 +94,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
workspaces.append(
|
workspaces.append(
|
||||||
{
|
{
|
||||||
'workspace': _workspace_payload(access.workspace),
|
'workspace': _workspace_payload(access.workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(access.membership, email=account.user),
|
||||||
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,
|
||||||
@@ -143,7 +137,6 @@ 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',
|
||||||
@@ -161,11 +154,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'workspace': _workspace_payload(workspace),
|
'workspace': _workspace_payload(workspace),
|
||||||
'membership': _membership_payload(
|
'membership': _membership_payload(membership, email=account.user),
|
||||||
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,
|
||||||
@@ -294,8 +283,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
data={
|
data={
|
||||||
'member': _membership_payload(
|
'member': _membership_payload(
|
||||||
member,
|
member,
|
||||||
display_name=account.user if account is not None else '',
|
email=account.user if account is not None else '',
|
||||||
email=account.normalized_email if account is not None else '',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -314,11 +302,7 @@ 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(
|
return _membership_payload(view.membership, email=view.email)
|
||||||
view.membership,
|
|
||||||
display_name=view.display_name,
|
|
||||||
email=view.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('invitations', '/api/v1/invitations')
|
@group.group_class('invitations', '/api/v1/invitations')
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -114,23 +113,6 @@ 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,
|
||||||
@@ -231,7 +213,6 @@ 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))
|
||||||
@@ -310,17 +291,11 @@ class LLMModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_llm_model(
|
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
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)
|
||||||
@@ -346,7 +321,6 @@ 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(
|
||||||
@@ -381,11 +355,6 @@ 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),
|
||||||
@@ -479,10 +448,7 @@ 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,
|
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
||||||
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()
|
||||||
@@ -506,7 +472,6 @@ 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(
|
||||||
@@ -565,17 +530,11 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_embedding_model(
|
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
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)
|
||||||
@@ -600,7 +559,6 @@ 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(
|
||||||
@@ -635,11 +593,6 @@ 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(
|
||||||
@@ -732,12 +685,7 @@ 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(
|
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||||
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:
|
||||||
@@ -760,7 +708,6 @@ 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(
|
||||||
@@ -819,17 +766,11 @@ class RerankModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_rerank_model(
|
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||||
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)
|
||||||
@@ -854,7 +795,6 @@ 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(
|
||||||
@@ -889,11 +829,6 @@ 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,7 +5,6 @@ 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
|
||||||
@@ -21,20 +20,6 @@ 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:
|
||||||
@@ -114,8 +99,6 @@ 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(
|
||||||
@@ -132,10 +115,7 @@ 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:
|
||||||
@@ -165,7 +145,6 @@ 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(
|
||||||
@@ -266,8 +245,6 @@ 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))
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ 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)
|
||||||
@@ -121,12 +117,7 @@ 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(
|
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||||
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
|
||||||
|
|
||||||
@@ -136,14 +127,7 @@ 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={
|
json={'code': code, 'instance_id': constants.instance_id},
|
||||||
'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)
|
||||||
|
|||||||
@@ -779,27 +779,8 @@ 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 and bind both installation and the active
|
# Exchange code for tokens
|
||||||
# OSS Workspace as independent identities.
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||||
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)
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ 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, 3072})
|
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
|
||||||
|
|
||||||
|
|
||||||
class CloudBootstrapError(RuntimeError):
|
class CloudBootstrapError(RuntimeError):
|
||||||
@@ -51,7 +50,6 @@ 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
|
||||||
|
|
||||||
@@ -82,7 +80,6 @@ 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)
|
||||||
@@ -113,8 +110,6 @@ 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:
|
||||||
@@ -143,14 +138,8 @@ 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', {})
|
||||||
box_enabled = box_config.get('enabled')
|
if box_config.get('enabled') is not True:
|
||||||
if box_enabled is False:
|
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||||
# 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()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc
|
|||||||
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceExecutionSource,
|
WorkspaceExecutionSource,
|
||||||
@@ -359,7 +358,6 @@ 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,
|
||||||
@@ -468,7 +466,6 @@ 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,
|
||||||
@@ -478,14 +475,6 @@ 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],
|
||||||
@@ -887,15 +876,15 @@ class DirectoryProjectionService:
|
|||||||
account_uuid=member.account_uuid,
|
account_uuid=member.account_uuid,
|
||||||
role=role,
|
role=role,
|
||||||
status=status,
|
status=status,
|
||||||
source=MembershipSource.CLOUD_PROJECTION.value,
|
|
||||||
joined_at=joined_at,
|
joined_at=joined_at,
|
||||||
projection_revision=member.projection_revision,
|
projection_revision=member.projection_revision,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if membership.source != MembershipSource.CLOUD_PROJECTION.value:
|
if membership.projection_revision == 0:
|
||||||
# Core-owned collaboration state is never adopted based on
|
# Revision zero is Core-owned collaboration state. Directory
|
||||||
# account provenance, revision, or matching account identity.
|
# projection seeds memberships, but must not overwrite later
|
||||||
|
# invitation, role, or removal decisions made by Core.
|
||||||
continue
|
continue
|
||||||
if membership.uuid != member.membership_uuid:
|
if membership.uuid != member.membership_uuid:
|
||||||
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
||||||
@@ -907,12 +896,11 @@ class DirectoryProjectionService:
|
|||||||
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
||||||
membership.role = role
|
membership.role = role
|
||||||
membership.status = status
|
membership.status = status
|
||||||
membership.source = MembershipSource.CLOUD_PROJECTION.value
|
|
||||||
membership.joined_at = joined_at
|
membership.joined_at = joined_at
|
||||||
membership.projection_revision = member.projection_revision
|
membership.projection_revision = member.projection_revision
|
||||||
|
|
||||||
for account_uuid, membership in existing.items():
|
for account_uuid, membership in existing.items():
|
||||||
if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value:
|
if account_uuid not in included_accounts and membership.projection_revision != 0:
|
||||||
membership.status = MembershipStatus.REMOVED.value
|
membership.status = MembershipStatus.REMOVED.value
|
||||||
membership.projection_revision = max(
|
membership.projection_revision = max(
|
||||||
int(membership.projection_revision),
|
int(membership.projection_revision),
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
||||||
|
|
||||||
from ..entity.persistence import model as persistence_model
|
|
||||||
|
|
||||||
|
|
||||||
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
|
|
||||||
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
|
|
||||||
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
|
|
||||||
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
|
|
||||||
_MODEL_TABLES = (
|
|
||||||
persistence_model.LLMModel,
|
|
||||||
persistence_model.EmbeddingModel,
|
|
||||||
persistence_model.RerankModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
model_id: str = Field(min_length=1, max_length=255)
|
|
||||||
category: Literal['chat', 'embedding', 'rerank']
|
|
||||||
llm_abilities: tuple[str, ...] = ()
|
|
||||||
is_featured: bool = False
|
|
||||||
featured_order: int = 0
|
|
||||||
|
|
||||||
@field_validator('llm_abilities', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def normalize_missing_abilities(cls, value: Any) -> Any:
|
|
||||||
return () if value is None else value
|
|
||||||
|
|
||||||
@field_validator('llm_abilities')
|
|
||||||
@classmethod
|
|
||||||
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
||||||
if any(not item.strip() or len(item) > 64 for item in value):
|
|
||||||
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
|
|
||||||
if len(set(value)) != len(value):
|
|
||||||
raise ValueError('Model abilities must be unique')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class CloudWorkspaceModelBilling(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
workspace_uuid: str = Field(min_length=36, max_length=36)
|
|
||||||
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
|
|
||||||
api_key: SecretStr | None = None
|
|
||||||
credits: int | None = None
|
|
||||||
|
|
||||||
@field_validator('workspace_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_uuid(cls, value: str) -> str:
|
|
||||||
return str(uuid.UUID(value))
|
|
||||||
|
|
||||||
@field_validator('owner_account_uuid')
|
|
||||||
@classmethod
|
|
||||||
def validate_optional_uuid(cls, value: str | None) -> str | None:
|
|
||||||
return None if value is None else str(uuid.UUID(value))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSnapshot(BaseModel):
|
|
||||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
|
||||||
|
|
||||||
instance_uuid: str = Field(min_length=1, max_length=255)
|
|
||||||
generated_at: datetime
|
|
||||||
base_url: str = Field(min_length=1, max_length=512)
|
|
||||||
models: tuple[CloudModelCatalogItem, ...]
|
|
||||||
workspaces: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
|
|
||||||
@field_validator('base_url')
|
|
||||||
@classmethod
|
|
||||||
def validate_base_url(cls, value: str) -> str:
|
|
||||||
normalized = value.rstrip('/')
|
|
||||||
if not normalized.startswith('https://'):
|
|
||||||
raise ValueError('Cloud model gateway base URL must use HTTPS')
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@field_validator('models')
|
|
||||||
@classmethod
|
|
||||||
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
|
|
||||||
if len(value) > 500:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 500 models')
|
|
||||||
identities = {(item.category, item.uuid) for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate model identities')
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator('workspaces')
|
|
||||||
@classmethod
|
|
||||||
def validate_workspaces(
|
|
||||||
cls, value: tuple[CloudWorkspaceModelBilling, ...]
|
|
||||||
) -> tuple[CloudWorkspaceModelBilling, ...]:
|
|
||||||
if len(value) > 10_000:
|
|
||||||
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
|
|
||||||
identities = {item.workspace_uuid for item in value}
|
|
||||||
if len(identities) != len(value):
|
|
||||||
raise ValueError('Cloud model catalog contains duplicate Workspaces')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class CloudModelCatalogProvider(Protocol):
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
"""Fetch and verify the complete model catalog and Workspace billing projection."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def system_provider_uuid(workspace_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
|
|
||||||
|
|
||||||
|
|
||||||
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
|
|
||||||
workspace = str(uuid.UUID(workspace_uuid))
|
|
||||||
if category not in _SUPPORTED_CATEGORIES:
|
|
||||||
raise ValueError(f'Unsupported model category: {category}')
|
|
||||||
if not upstream_uuid:
|
|
||||||
raise ValueError('Upstream model UUID is required')
|
|
||||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
|
|
||||||
|
|
||||||
|
|
||||||
class CloudModelCatalogSyncService:
|
|
||||||
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
ap: Any,
|
|
||||||
provider: CloudModelCatalogProvider,
|
|
||||||
instance_uuid: str,
|
|
||||||
*,
|
|
||||||
sync_interval_seconds: float = 3600.0,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(provider, CloudModelCatalogProvider):
|
|
||||||
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
|
|
||||||
if sync_interval_seconds < 10:
|
|
||||||
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
|
|
||||||
self.ap = ap
|
|
||||||
self.provider = provider
|
|
||||||
self.instance_uuid = instance_uuid
|
|
||||||
self.sync_interval_seconds = float(sync_interval_seconds)
|
|
||||||
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
|
|
||||||
# convergence marker so a failed runtime reload is retried even when the
|
|
||||||
# following database reconciliation is a no-op.
|
|
||||||
self._runtime_reload_pending = False
|
|
||||||
self._workspace_credits: dict[str, int | None] = {}
|
|
||||||
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}')
|
|
||||||
@@ -54,7 +54,6 @@ from ..cloud import launch as cloud_launch_module
|
|||||||
from ..cloud import support_admin as cloud_support_admin_module
|
from ..cloud import 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
|
||||||
|
|
||||||
|
|
||||||
@@ -143,12 +142,13 @@ 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,12 +306,6 @@ 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(),
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ from ...cloud import support_admin as cloud_support_admin_module
|
|||||||
from ...cloud.directory import directory_projection_limits_from_config
|
from ...cloud.directory 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
|
||||||
|
|
||||||
@@ -177,16 +176,6 @@ 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,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ _RUNTIME_POLICY_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'plugin': {
|
'plugin': {
|
||||||
'connect_timeout_seconds': 180.0,
|
|
||||||
'worker': {
|
'worker': {
|
||||||
'max_cpus': 1.0,
|
'max_cpus': 1.0,
|
||||||
'max_memory_mb': 512,
|
'max_memory_mb': 512,
|
||||||
@@ -57,7 +56,7 @@ _RUNTIME_POLICY_DEFAULTS = {
|
|||||||
'restart_failure_window_seconds': 30.0,
|
'restart_failure_window_seconds': 30.0,
|
||||||
'restart_circuit_open_seconds': 60.0,
|
'restart_circuit_open_seconds': 60.0,
|
||||||
'require_hard_limits': False,
|
'require_hard_limits': False,
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': True}},
|
'mcp': {'stdio': {'enabled': True}},
|
||||||
'monitoring': {
|
'monitoring': {
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ class MembershipStatus(enum.StrEnum):
|
|||||||
REMOVED = 'removed'
|
REMOVED = 'removed'
|
||||||
|
|
||||||
|
|
||||||
class MembershipSource(enum.StrEnum):
|
|
||||||
LOCAL = 'local'
|
|
||||||
CLOUD_PROJECTION = 'cloud_projection'
|
|
||||||
|
|
||||||
|
|
||||||
class InvitationStatus(enum.StrEnum):
|
class InvitationStatus(enum.StrEnum):
|
||||||
PENDING = 'pending'
|
PENDING = 'pending'
|
||||||
ACCEPTED = 'accepted'
|
ACCEPTED = 'accepted'
|
||||||
@@ -156,11 +151,6 @@ class WorkspaceMembership(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
source = sqlalchemy.Column(
|
|
||||||
sqlalchemy.String(32),
|
|
||||||
nullable=False,
|
|
||||||
server_default=MembershipSource.LOCAL.value,
|
|
||||||
)
|
|
||||||
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
@@ -173,13 +163,6 @@ 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',
|
||||||
@@ -188,10 +171,6 @@ class WorkspaceMembership(Base):
|
|||||||
"status IN ('active', 'disabled', 'removed')",
|
"status IN ('active', 'disabled', 'removed')",
|
||||||
name='ck_workspace_memberships_status',
|
name='ck_workspace_memberships_status',
|
||||||
),
|
),
|
||||||
sqlalchemy.CheckConstraint(
|
|
||||||
"source IN ('local', 'cloud_projection')",
|
|
||||||
name='ck_workspace_memberships_source',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,17 +18,6 @@ down_revision = '0008_mcp_resource_prefs'
|
|||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
|
|
||||||
|
|
||||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
value = instance_id.strip()
|
|
||||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _table_names(conn: sa.Connection) -> set[str]:
|
def _table_names(conn: sa.Connection) -> set[str]:
|
||||||
return set(sa.inspect(conn).get_table_names())
|
return set(sa.inspect(conn).get_table_names())
|
||||||
@@ -414,7 +403,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None:
|
|||||||
.values(created_by_account_uuid=owner_account_uuid)
|
.values(created_by_account_uuid=owner_account_uuid)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
|
workspace_uuid = str(uuid.uuid4())
|
||||||
conn.execute(
|
conn.execute(
|
||||||
workspaces.insert().values(
|
workspaces.insert().values(
|
||||||
uuid=workspace_uuid,
|
uuid=workspace_uuid,
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""add durable replay protection for signed Space launch assertions
|
|
||||||
|
|
||||||
Revision ID: 0016_space_launch_replay
|
|
||||||
Revises: 0015_cloud_core_collab
|
|
||||||
Create Date: 2026-07-31
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0016_space_launch_replay'
|
|
||||||
down_revision = '0015_cloud_core_collab'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_TABLE = 'space_launch_assertion_consumptions'
|
|
||||||
_POLICY = 'langbot_directory_projection'
|
|
||||||
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if _TABLE not in set(sa.inspect(conn).get_table_names()):
|
|
||||||
op.create_table(
|
|
||||||
_TABLE,
|
|
||||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
|
||||||
sa.Column('jti', sa.String(255), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
'ix_space_launch_assertion_consumptions_expiry',
|
|
||||||
_TABLE,
|
|
||||||
['instance_uuid', 'expires_at'],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
if conn.dialect.name == 'postgresql':
|
|
||||||
table = conn.dialect.identifier_preparer.quote(_TABLE)
|
|
||||||
policy = conn.dialect.identifier_preparer.quote(_POLICY)
|
|
||||||
expression = f'instance_uuid::text = {_SETTING}'
|
|
||||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
|
||||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
|
||||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
|
||||||
f'USING ({expression}) WITH CHECK ({expression})'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
|
|
||||||
op.drop_table(_TABLE)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""align the OSS Workspace UUID with the persisted instance identity
|
|
||||||
|
|
||||||
Revision ID: 0017_oss_workspace_identity
|
|
||||||
Revises: 0016_support_admin_sessions
|
|
||||||
Create Date: 2026-07-31
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
|
|
||||||
revision = '0017_oss_workspace_identity'
|
|
||||||
down_revision = '0016_support_admin_sessions'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
|
||||||
|
|
||||||
|
|
||||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
value = instance_id.strip()
|
|
||||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
|
||||||
return conn.dialect.identifier_preparer.quote(identifier)
|
|
||||||
|
|
||||||
|
|
||||||
def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None:
|
|
||||||
"""Allow the transaction to re-key a connected tenant graph atomically."""
|
|
||||||
|
|
||||||
if conn.dialect.name == 'sqlite':
|
|
||||||
conn.execute(sa.text('PRAGMA defer_foreign_keys = ON'))
|
|
||||||
return
|
|
||||||
if conn.dialect.name != 'postgresql':
|
|
||||||
raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}')
|
|
||||||
|
|
||||||
for table_name in table_names:
|
|
||||||
for foreign_key in inspector.get_foreign_keys(table_name):
|
|
||||||
constraint_name = foreign_key.get('name')
|
|
||||||
if not constraint_name:
|
|
||||||
continue
|
|
||||||
conn.execute(
|
|
||||||
sa.text(
|
|
||||||
f'ALTER TABLE {_quote(conn, table_name)} '
|
|
||||||
f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _suspend_postgres_rls(
|
|
||||||
conn: sa.Connection,
|
|
||||||
table_names: list[str],
|
|
||||||
) -> dict[str, tuple[bool, bool]]:
|
|
||||||
if conn.dialect.name != 'postgresql':
|
|
||||||
return {}
|
|
||||||
|
|
||||||
states: dict[str, tuple[bool, bool]] = {}
|
|
||||||
for table_name in table_names:
|
|
||||||
row = conn.execute(
|
|
||||||
sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'),
|
|
||||||
{'table_name': table_name},
|
|
||||||
).one()
|
|
||||||
enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
|
|
||||||
states[table_name] = (enabled, forced)
|
|
||||||
table = _quote(conn, table_name)
|
|
||||||
if forced:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
|
||||||
if enabled:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
|
||||||
return states
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None:
|
|
||||||
for table_name, (enabled, forced) in states.items():
|
|
||||||
table = _quote(conn, table_name)
|
|
||||||
if enabled:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
|
||||||
if forced:
|
|
||||||
conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
table_names = inspector.get_table_names()
|
|
||||||
if 'workspaces' not in table_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
metadata = sa.MetaData()
|
|
||||||
workspaces = sa.Table('workspaces', metadata, autoload_with=conn)
|
|
||||||
local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all()
|
|
||||||
if not local_rows:
|
|
||||||
return
|
|
||||||
if len(local_rows) != 1:
|
|
||||||
raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace')
|
|
||||||
|
|
||||||
old_row = dict(local_rows[0])
|
|
||||||
old_uuid = old_row['uuid']
|
|
||||||
canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid'])
|
|
||||||
if old_uuid == canonical_uuid:
|
|
||||||
return
|
|
||||||
if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none():
|
|
||||||
raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists')
|
|
||||||
|
|
||||||
tenant_tables = [
|
|
||||||
table_name
|
|
||||||
for table_name in table_names
|
|
||||||
if table_name == 'workspaces'
|
|
||||||
or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)}
|
|
||||||
]
|
|
||||||
rls_states = _suspend_postgres_rls(conn, tenant_tables)
|
|
||||||
try:
|
|
||||||
_defer_foreign_keys(conn, inspector, table_names)
|
|
||||||
|
|
||||||
# Release local source/slug uniqueness while the canonical parent exists
|
|
||||||
# alongside the old parent for the duration of this transaction.
|
|
||||||
temporary_slug = f'__workspace_rekey__{old_uuid}'
|
|
||||||
conn.execute(
|
|
||||||
workspaces.update()
|
|
||||||
.where(workspaces.c.uuid == old_uuid)
|
|
||||||
.values(source='cloud_projection', slug=temporary_slug)
|
|
||||||
)
|
|
||||||
new_row = dict(old_row)
|
|
||||||
new_row['uuid'] = canonical_uuid
|
|
||||||
conn.execute(workspaces.insert().values(**new_row))
|
|
||||||
|
|
||||||
for table_name in tenant_tables:
|
|
||||||
if table_name == 'workspaces':
|
|
||||||
continue
|
|
||||||
table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True)
|
|
||||||
conn.execute(table.update().where(table.c.workspace_uuid == old_uuid).values(workspace_uuid=canonical_uuid))
|
|
||||||
|
|
||||||
if 'metadata' in table_names:
|
|
||||||
conn.execute(
|
|
||||||
sa.text('UPDATE metadata SET value = :canonical_uuid WHERE key = :key AND value = :old_uuid'),
|
|
||||||
{
|
|
||||||
'canonical_uuid': canonical_uuid,
|
|
||||||
'key': _OSS_WORKSPACE_METADATA_KEY,
|
|
||||||
'old_uuid': old_uuid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid))
|
|
||||||
if conn.dialect.name == 'postgresql':
|
|
||||||
# Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL
|
|
||||||
# rejects ALTER TABLE while a relation has pending trigger events.
|
|
||||||
conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
|
|
||||||
except Exception:
|
|
||||||
# Alembic owns the transaction. Rollback restores the transactional RLS DDL.
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
_restore_postgres_rls(conn, rls_states)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# The previous random UUID is intentionally not recoverable. Keeping the
|
|
||||||
# canonical identity preserves every FK and is safe for older application code.
|
|
||||||
pass
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""merge the published Space launch replay and main migration branches
|
|
||||||
|
|
||||||
Revision ID: 0018_merge_launch_replay
|
|
||||||
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
|
|
||||||
Create Date: 2026-08-01
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
revision = '0018_merge_launch_replay'
|
|
||||||
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""enforce one active owner per Workspace
|
|
||||||
|
|
||||||
Revision ID: 0019_single_workspace_owner
|
|
||||||
Revises: 0018_merge_launch_replay
|
|
||||||
Create Date: 2026-08-02
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0019_single_workspace_owner'
|
|
||||||
down_revision = '0018_merge_launch_replay'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Ownership transfer used to promote a second member without demoting the
|
|
||||||
# original owner. Preserve the Workspace creator where possible and demote
|
|
||||||
# every historical extra owner before installing the database invariant.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
WITH ranked_owners AS (
|
|
||||||
SELECT membership.uuid,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY membership.workspace_uuid
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
COALESCE(membership.joined_at, membership.created_at),
|
|
||||||
membership.uuid
|
|
||||||
) AS owner_rank
|
|
||||||
FROM workspace_memberships AS membership
|
|
||||||
JOIN workspaces AS workspace
|
|
||||||
ON workspace.uuid = membership.workspace_uuid
|
|
||||||
WHERE membership.role = 'owner'
|
|
||||||
AND membership.status = 'active'
|
|
||||||
)
|
|
||||||
UPDATE workspace_memberships
|
|
||||||
SET role = 'admin'
|
|
||||||
WHERE uuid IN (
|
|
||||||
SELECT uuid
|
|
||||||
FROM ranked_owners
|
|
||||||
WHERE owner_rank > 1
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Fresh installations may already have this index because SQLAlchemy
|
|
||||||
# metadata is created before Alembic advances the revision marker.
|
|
||||||
op.execute(
|
|
||||||
sa.text(
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
|
||||||
'uq_workspace_memberships_one_active_owner '
|
|
||||||
'ON workspace_memberships (workspace_uuid) '
|
|
||||||
"WHERE role = 'owner' AND status = 'active'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
|
|
||||||
if _INDEX_NAME in index_names:
|
|
||||||
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""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)')
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""add explicit Workspace membership source
|
|
||||||
|
|
||||||
Revision ID: 0020_membership_source
|
|
||||||
Revises: 001a_pgvector_dimension_3072
|
|
||||||
Create Date: 2026-08-06
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0020_membership_source'
|
|
||||||
down_revision = '001a_pgvector_dimension_3072'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_CONSTRAINT_NAME = 'ck_workspace_memberships_source'
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
|
||||||
return
|
|
||||||
|
|
||||||
# No durable historical field distinguishes Directory-created revision-zero
|
|
||||||
# rows from Core invitations. Protect every existing row; production can
|
|
||||||
# reclassify separately after UUIDs have been verified against Space.
|
|
||||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local'))
|
|
||||||
batch_op.create_check_constraint(
|
|
||||||
_CONSTRAINT_NAME,
|
|
||||||
"source IN ('local', 'cloud_projection')",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'workspace_memberships' not in inspector.get_table_names():
|
|
||||||
return
|
|
||||||
if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
|
||||||
return
|
|
||||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
|
||||||
batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check')
|
|
||||||
batch_op.drop_column('source')
|
|
||||||
@@ -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, 3072)
|
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
_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,16 +1356,14 @@ 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_type}({dimension})' not in index_definition
|
or f'vector({dimension})' not in index_definition
|
||||||
or f'(embedding)::{vector_type}({dimension})' not in index_definition
|
or f'(embedding)::vector({dimension})' not in index_definition
|
||||||
or operator_class not in index_definition
|
or 'vector_cosine_ops' 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')
|
||||||
|
|||||||
@@ -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 HALFVEC, Vector
|
from pgvector.sqlalchemy import 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({'date_trunc', 'length', 'nullif'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'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) in {Vector, HALFVEC}:
|
if type(sql_type) is Vector:
|
||||||
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) not in {Vector, HALFVEC}:
|
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
|
||||||
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'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ class Controller:
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not selected_query: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
if selected_query: # 找到了
|
||||||
|
queries.remove(selected_query)
|
||||||
|
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
||||||
await self.ap.query_pool.condition.wait()
|
await self.ap.query_pool.condition.wait()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ....provider import runner as runner_module
|
|||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ....utils import importutil, constants, runner as runner_utils
|
from ....utils import importutil, constants, runner as runner_utils
|
||||||
from ....telemetry import features as telemetry_features
|
from ....telemetry import features as telemetry_features
|
||||||
from ....telemetry.identity import workspace_identity
|
|
||||||
from ....provider import runners
|
from ....provider import runners
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -266,8 +265,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'duration_ms': duration_ms,
|
'duration_ms': duration_ms,
|
||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
**workspace_identity(get_query_execution_context(query)),
|
'instance_id': constants.instance_id,
|
||||||
'runtime_instance_id': constants.instance_id,
|
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'features': features,
|
'features': features,
|
||||||
|
|||||||
@@ -707,37 +707,28 @@ 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
|
||||||
listener = typing.cast(
|
token = _current_pipeline_uuid.set(pipeline_uuid)
|
||||||
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
|
try:
|
||||||
listeners[event.__class__],
|
task_manager = getattr(self.ap, 'task_mgr', None)
|
||||||
)
|
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
||||||
|
listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||||
async def run_listener():
|
else:
|
||||||
token = _current_pipeline_uuid.set(pipeline_uuid)
|
listener_task = task_manager.create_task(
|
||||||
try:
|
listeners[event.__class__](event, callback_adapter),
|
||||||
await listener(event, callback_adapter)
|
kind='websocket-message',
|
||||||
finally:
|
name=f'websocket-message-{connection.connection_id}',
|
||||||
_current_pipeline_uuid.reset(token)
|
scopes=[
|
||||||
|
core_entities.LifecycleControlScope.APPLICATION,
|
||||||
listener_coro = run_listener()
|
core_entities.LifecycleControlScope.PLATFORM,
|
||||||
task_manager = getattr(self.ap, 'task_mgr', None)
|
],
|
||||||
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
instance_uuid=connection.instance_uuid,
|
||||||
listener_task = asyncio.create_task(listener_coro)
|
workspace_uuid=connection.workspace_uuid,
|
||||||
else:
|
placement_generation=connection.placement_generation,
|
||||||
listener_task = task_manager.create_task(
|
).task
|
||||||
listener_coro,
|
listener_tasks.add(listener_task)
|
||||||
kind='websocket-message',
|
listener_task.add_done_callback(self._listener_task_done)
|
||||||
name=f'websocket-message-{connection.connection_id}',
|
finally:
|
||||||
scopes=[
|
_current_pipeline_uuid.reset(token)
|
||||||
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,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import contextlib
|
|||||||
import contextvars
|
import contextvars
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -77,7 +76,7 @@ _GITHUB_ASSET_HOSTS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
||||||
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 180.0
|
_CONNECT_TIMEOUT_SEC = 30.0
|
||||||
_HEARTBEAT_INTERVAL_SEC = 20.0
|
_HEARTBEAT_INTERVAL_SEC = 20.0
|
||||||
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
||||||
_RECONNECT_MAX_DELAY_SEC = 60.0
|
_RECONNECT_MAX_DELAY_SEC = 60.0
|
||||||
@@ -207,17 +206,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return f'{constants.instance_id}:plugin-runtime'
|
return f'{constants.instance_id}:plugin-runtime'
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runtime_connect_timeout(plugin_config: dict[str, Any]) -> float:
|
|
||||||
value = plugin_config.get('connect_timeout_seconds', _DEFAULT_CONNECT_TIMEOUT_SECONDS)
|
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
|
||||||
raise ValueError('plugin.connect_timeout_seconds must be a positive number')
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runtime_connect_timeout_error(timeout_seconds: float) -> str:
|
|
||||||
return f'Plugin runtime did not become ready within {timeout_seconds:g} seconds'
|
|
||||||
|
|
||||||
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
||||||
runtime_handler = getattr(self, 'handler', None)
|
runtime_handler = getattr(self, 'handler', None)
|
||||||
if runtime_handler is None:
|
if runtime_handler is None:
|
||||||
@@ -263,8 +251,6 @@ 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,
|
||||||
@@ -713,13 +699,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
runtime_handler = self._runtime_handler()
|
runtime_handler = self._runtime_handler()
|
||||||
started_at = time.monotonic()
|
|
||||||
async with self._state_lock:
|
async with self._state_lock:
|
||||||
all_states: dict[str, PluginInstallationDesiredState] = {}
|
all_states: dict[str, PluginInstallationDesiredState] = {}
|
||||||
workspace_installations: dict[str, set[str]] = {}
|
workspace_installations: dict[str, set[str]] = {}
|
||||||
workspace_count = 0
|
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
workspace_count += 1
|
|
||||||
execution_context = await self._validate_execution_context(context)
|
execution_context = await self._validate_execution_context(context)
|
||||||
states = await self._load_workspace_desired_states(execution_context)
|
states = await self._load_workspace_desired_states(execution_context)
|
||||||
installation_ids = {state.binding.installation_uuid for state in states}
|
installation_ids = {state.binding.installation_uuid for state in states}
|
||||||
@@ -739,13 +722,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
runtime_handler.unregister_installation_binding(previous.binding)
|
runtime_handler.unregister_installation_binding(previous.binding)
|
||||||
self._known_desired_states = all_states
|
self._known_desired_states = all_states
|
||||||
self._workspace_installations = workspace_installations
|
self._workspace_installations = workspace_installations
|
||||||
self.ap.logger.info(
|
|
||||||
'Shared plugin runtime reconcile completed: workspaces=%d desired_installations=%d '
|
|
||||||
'elapsed_seconds=%.3f',
|
|
||||||
workspace_count,
|
|
||||||
len(all_states),
|
|
||||||
time.monotonic() - started_at,
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
|
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||||
@@ -841,8 +817,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
runtime_id=self._runtime_id,
|
runtime_id=self._runtime_id,
|
||||||
)
|
)
|
||||||
self.worker_policy = self._load_worker_policy()
|
self.worker_policy = self._load_worker_policy()
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
|
||||||
connect_timeout_seconds = self._runtime_connect_timeout(plugin_config)
|
|
||||||
|
|
||||||
async with self._lifecycle_lock:
|
async with self._lifecycle_lock:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
@@ -984,12 +958,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
self._transport_task = asyncio.create_task(task_coro)
|
self._transport_task = asyncio.create_task(task_coro)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._connected.wait(), timeout=connect_timeout_seconds)
|
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
await self._stop_transport()
|
await self._stop_transport()
|
||||||
raise PluginRuntimeNotConnectedError(
|
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
|
||||||
self._runtime_connect_timeout_error(connect_timeout_seconds)
|
|
||||||
) from exc
|
|
||||||
if connect_errors:
|
if connect_errors:
|
||||||
await self._stop_transport()
|
await self._stop_transport()
|
||||||
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
||||||
@@ -1996,11 +1968,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, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> 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(execution_context)
|
return await self._runtime_handler().get_debug_info()
|
||||||
|
|
||||||
async def emit_event(
|
async def emit_event(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1960,14 +1960,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""Get debug information including debug key and WS URL"""
|
||||||
result = await self.call_action(
|
with self.installation_scope(None):
|
||||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
result = await self.call_action(
|
||||||
{},
|
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||||
timeout=10,
|
{},
|
||||||
action_context=execution_context,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
# ================= RAG Capability Callers (LangBot -> Runtime) =================
|
||||||
|
|||||||
@@ -27,19 +27,6 @@ if typing.TYPE_CHECKING:
|
|||||||
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceResourceSnapshot(typing.TypedDict):
|
|
||||||
workspace_uuid: str
|
|
||||||
bot_count: int
|
|
||||||
pipeline_count: int
|
|
||||||
knowledge_base_count: int
|
|
||||||
plugin_count: int
|
|
||||||
mcp_server_count: int
|
|
||||||
extension_count: int
|
|
||||||
skill_count: int
|
|
||||||
adapters: list[str]
|
|
||||||
execution_generation: int
|
|
||||||
|
|
||||||
|
|
||||||
async def _count(
|
async def _count(
|
||||||
ap: core_app.Application,
|
ap: core_app.Application,
|
||||||
table,
|
table,
|
||||||
@@ -65,13 +52,14 @@ async def _count(
|
|||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]:
|
async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]:
|
||||||
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
|
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
|
||||||
persistence_mgr = ap.persistence_mgr
|
persistence_mgr = ap.persistence_mgr
|
||||||
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
|
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
|
||||||
return []
|
return []
|
||||||
|
|
||||||
counts: dict[str, WorkspaceResourceSnapshot] = {
|
bindings = await ap.workspace_service.list_active_execution_bindings()
|
||||||
|
counts = {
|
||||||
binding.workspace_uuid: {
|
binding.workspace_uuid: {
|
||||||
'workspace_uuid': binding.workspace_uuid,
|
'workspace_uuid': binding.workspace_uuid,
|
||||||
'bot_count': 0,
|
'bot_count': 0,
|
||||||
@@ -80,20 +68,13 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
|
|||||||
'plugin_count': 0,
|
'plugin_count': 0,
|
||||||
'mcp_server_count': 0,
|
'mcp_server_count': 0,
|
||||||
'extension_count': 0,
|
'extension_count': 0,
|
||||||
'skill_count': 0,
|
|
||||||
'adapters': [],
|
|
||||||
'execution_generation': binding.placement_generation,
|
|
||||||
}
|
}
|
||||||
for binding in bindings
|
for binding in bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter_sets: dict[str, set[str]] = {workspace_uuid: set() for workspace_uuid in counts}
|
for key in getattr(ap.platform_mgr, '_bots_by_key', {}):
|
||||||
for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items():
|
|
||||||
if len(key) >= 2 and key[1] in counts:
|
if len(key) >= 2 and key[1] in counts:
|
||||||
counts[key[1]]['bot_count'] += 1
|
counts[key[1]]['bot_count'] += 1
|
||||||
adapter = getattr(bot, 'adapter', None)
|
|
||||||
if adapter is not None and getattr(bot, 'enable', False):
|
|
||||||
adapter_sets[key[1]].add(adapter.__class__.__name__)
|
|
||||||
for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}):
|
for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}):
|
||||||
if len(key) >= 2 and key[1] in counts:
|
if len(key) >= 2 and key[1] in counts:
|
||||||
counts[key[1]]['pipeline_count'] += 1
|
counts[key[1]]['pipeline_count'] += 1
|
||||||
@@ -106,24 +87,14 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -
|
|||||||
for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items():
|
for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items():
|
||||||
if workspace_uuid in counts:
|
if workspace_uuid in counts:
|
||||||
counts[workspace_uuid]['plugin_count'] = len(installations)
|
counts[workspace_uuid]['plugin_count'] = len(installations)
|
||||||
for key, skills in getattr(ap.skill_mgr, '_skills_by_scope', {}).items():
|
|
||||||
if len(key) >= 2 and key[1] in counts:
|
|
||||||
counts[key[1]]['skill_count'] += len(skills)
|
|
||||||
|
|
||||||
for workspace_uuid, resource in counts.items():
|
for resource in counts.values():
|
||||||
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
|
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
|
||||||
resource['adapters'] = sorted(adapter_sets[workspace_uuid])
|
|
||||||
return list(counts.values())
|
return list(counts.values())
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payload(
|
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||||
ap: core_app.Application,
|
"""Collect the anonymous instance profile snapshot."""
|
||||||
*,
|
|
||||||
workspace_uuid: str,
|
|
||||||
workspace_create_ts: int = 0,
|
|
||||||
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Collect one anonymous Workspace profile snapshot."""
|
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
from ..entity.persistence import mcp as persistence_mcp
|
from ..entity.persistence import mcp as persistence_mcp
|
||||||
from ..entity.persistence import pipeline as persistence_pipeline
|
from ..entity.persistence import pipeline as persistence_pipeline
|
||||||
@@ -206,16 +177,15 @@ async def build_heartbeat_payload(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if workspace_resource is not None:
|
workspace_resources = await _cloud_workspace_resource_counts(ap)
|
||||||
features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'})
|
if workspace_resources:
|
||||||
|
features['workspace_resources'] = workspace_resources
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'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,
|
'instance_id': constants.instance_id,
|
||||||
'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,
|
||||||
@@ -223,49 +193,14 @@ async def build_heartbeat_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_created_timestamp(created_at: datetime | None) -> int:
|
|
||||||
if created_at is None:
|
|
||||||
return 0
|
|
||||||
if created_at.tzinfo is None:
|
|
||||||
# SQLAlchemy may return persisted UTC values without tzinfo. Never
|
|
||||||
# reinterpret them in the host's local timezone.
|
|
||||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
||||||
return int(created_at.timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
|
||||||
"""Build one heartbeat per active Workspace."""
|
|
||||||
bindings = await ap.workspace_service.list_active_execution_bindings()
|
|
||||||
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
|
||||||
workspace_create_ts = {
|
|
||||||
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
|
|
||||||
for binding in bindings
|
|
||||||
}
|
|
||||||
resources = {
|
|
||||||
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
await build_heartbeat_payload(
|
|
||||||
ap,
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
|
|
||||||
workspace_resource=resources.get(workspace_uuid),
|
|
||||||
)
|
|
||||||
for workspace_uuid in workspace_uuids
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat_loop(ap: core_app.Application) -> None:
|
async def heartbeat_loop(ap: core_app.Application) -> None:
|
||||||
"""Send one heartbeat shortly after startup, then daily."""
|
"""Send one heartbeat shortly after startup, then daily."""
|
||||||
# Small delay so managers (platform, skills, plugins) finish loading first
|
# Small delay so managers (platform, skills, plugins) finish loading first
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for payload in await build_heartbeat_payloads(ap):
|
payload = await build_heartbeat_payload(ap)
|
||||||
# Heartbeats are a daily bounded batch, not best-effort query events.
|
await ap.telemetry.start_send_task(payload)
|
||||||
# Await each send so the TelemetryManager's 8-task queue cannot drop
|
|
||||||
# Workspaces after the first batch.
|
|
||||||
await ap.telemetry.send(payload)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceExecutionContext(typing.Protocol):
|
|
||||||
@property
|
|
||||||
def instance_uuid(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace_uuid(self) -> str: ...
|
|
||||||
|
|
||||||
|
|
||||||
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
|
||||||
"""Build both first-class telemetry identities for one execution."""
|
|
||||||
instance_id = execution_context.instance_uuid.strip()
|
|
||||||
workspace_uuid = execution_context.workspace_uuid.strip()
|
|
||||||
if not instance_id:
|
|
||||||
raise ValueError('Telemetry execution instance ID is empty')
|
|
||||||
if not workspace_uuid:
|
|
||||||
raise ValueError('Telemetry execution Workspace UUID is empty')
|
|
||||||
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
|
|
||||||
@@ -2,11 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..core import app as core_app
|
from ..core import app as core_app
|
||||||
from ..utils import httpclient
|
from ..utils import httpclient
|
||||||
|
|
||||||
@@ -25,7 +21,7 @@ class TelemetryManager:
|
|||||||
def __init__(self, ap: core_app.Application):
|
def __init__(self, ap: core_app.Application):
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
self.telemetry_config: dict[str, typing.Any] = {}
|
self.telemetry_config = {}
|
||||||
self.send_tasks: list[asyncio.Task] = []
|
self.send_tasks: list[asyncio.Task] = []
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
@@ -135,35 +131,7 @@ class TelemetryManager:
|
|||||||
async with self._client_context() as client:
|
async with self._client_context() as client:
|
||||||
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()
|
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
|
||||||
headers: dict[str, str] = {}
|
|
||||||
if telemetry_token:
|
|
||||||
headers['X-LangBot-Telemetry-Token'] = telemetry_token
|
|
||||||
else:
|
|
||||||
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
|
|
||||||
user_service = getattr(self.ap, 'user_service', None)
|
|
||||||
if workspace_uuid and user_service is not None:
|
|
||||||
try:
|
|
||||||
owner = await user_service.get_workspace_owner(workspace_uuid)
|
|
||||||
owner_email = str(getattr(owner, 'user', '') or '').strip()
|
|
||||||
space_service = getattr(self.ap, 'space_service', None)
|
|
||||||
access_token = (
|
|
||||||
await space_service.get_valid_access_token(owner_email)
|
|
||||||
if owner_email and space_service is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
access_token = str(access_token or '').strip()
|
|
||||||
if access_token:
|
|
||||||
headers['Authorization'] = f'Bearer {access_token}'
|
|
||||||
except Exception:
|
|
||||||
self.ap.logger.debug(
|
|
||||||
'Could not resolve authenticated telemetry reporter', exc_info=True
|
|
||||||
)
|
|
||||||
if headers:
|
|
||||||
request = client.post(url, json=sanitized, headers=headers)
|
|
||||||
else:
|
|
||||||
request = client.post(url, json=sanitized)
|
|
||||||
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
body = await httpclient.response_text(resp, max_chars=200)
|
body = await httpclient.response_text(resp, max_chars=200)
|
||||||
@@ -175,8 +143,7 @@ class TelemetryManager:
|
|||||||
app_err = False
|
app_err = False
|
||||||
try:
|
try:
|
||||||
j = await httpclient.parse_json_response(resp)
|
j = await httpclient.parse_json_response(resp)
|
||||||
app_code = j.get('code') if isinstance(j, dict) else None
|
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
|
||||||
if app_code is not None and int(app_code) >= 400:
|
|
||||||
app_err = True
|
app_err = True
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
||||||
|
|||||||
@@ -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, 3072],
|
[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
common_options = {
|
common_options = {
|
||||||
'use_business_database': use_business_database,
|
'use_business_database': use_business_database,
|
||||||
|
|||||||
@@ -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 HALFVEC, Vector
|
from pgvector.sqlalchemy import 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, 3072)
|
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||||
|
|
||||||
# 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,12 +321,7 @@ 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(
|
typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension))
|
||||||
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(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from ..entity.persistence.user import AccountStatus, User
|
|||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
InvitationStatus,
|
InvitationStatus,
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceInvitation,
|
WorkspaceInvitation,
|
||||||
@@ -89,7 +88,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -296,7 +294,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, User.normalized_email)
|
sqlalchemy.select(WorkspaceMembership, User.user)
|
||||||
.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,
|
||||||
@@ -306,12 +304,8 @@ class WorkspaceCollaborationService:
|
|||||||
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
WorkspaceMemberView(
|
WorkspaceMemberView(membership=membership, email=email)
|
||||||
membership=membership,
|
for membership, email in (await active_session.execute(statement)).all()
|
||||||
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)
|
||||||
@@ -484,7 +478,6 @@ class WorkspaceCollaborationService:
|
|||||||
account_uuid=account_uuid,
|
account_uuid=account_uuid,
|
||||||
role=invitation.role,
|
role=invitation.role,
|
||||||
status=MembershipStatus.ACTIVE.value,
|
status=MembershipStatus.ACTIVE.value,
|
||||||
source=MembershipSource.LOCAL.value,
|
|
||||||
invited_by_account_uuid=invitation.created_by_account_uuid,
|
invited_by_account_uuid=invitation.created_by_account_uuid,
|
||||||
joined_at=now,
|
joined_at=now,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
@@ -493,7 +486,6 @@ class WorkspaceCollaborationService:
|
|||||||
elif membership.status != MembershipStatus.ACTIVE.value:
|
elif membership.status != MembershipStatus.ACTIVE.value:
|
||||||
membership.role = invitation.role
|
membership.role = invitation.role
|
||||||
membership.status = MembershipStatus.ACTIVE.value
|
membership.status = MembershipStatus.ACTIVE.value
|
||||||
membership.source = MembershipSource.LOCAL.value
|
|
||||||
membership.invited_by_account_uuid = invitation.created_by_account_uuid
|
membership.invited_by_account_uuid = invitation.created_by_account_uuid
|
||||||
membership.joined_at = now
|
membership.joined_at = now
|
||||||
|
|
||||||
@@ -614,8 +606,6 @@ 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)
|
||||||
@@ -627,8 +617,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:
|
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.role = role
|
target.role = role
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -654,7 +644,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:
|
||||||
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
||||||
target.status = MembershipStatus.REMOVED.value
|
target.status = MembershipStatus.REMOVED.value
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -761,6 +751,26 @@ 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')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -13,4 +12,3 @@ 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,20 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
_INSTANCE_PREFIX = 'instance_'
|
|
||||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
|
||||||
|
|
||||||
|
|
||||||
def workspace_uuid_from_instance_id(instance_id: str) -> str:
|
|
||||||
"""Return the stable OSS Workspace UUID for a persisted instance identity."""
|
|
||||||
value = instance_id.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError('LangBot instance identity is empty')
|
|
||||||
|
|
||||||
candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value
|
|
||||||
try:
|
|
||||||
return str(uuid.UUID(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
|
||||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
|
|
||||||
from ..entity.persistence.workspace import (
|
from ..entity.persistence.workspace import (
|
||||||
MembershipRole,
|
MembershipRole,
|
||||||
MembershipSource,
|
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceExecutionSource,
|
WorkspaceExecutionSource,
|
||||||
@@ -31,7 +30,6 @@ from .errors import (
|
|||||||
WorkspaceOwnerAlreadyExistsError,
|
WorkspaceOwnerAlreadyExistsError,
|
||||||
)
|
)
|
||||||
from .entities import WorkspaceExecutionBinding
|
from .entities import WorkspaceExecutionBinding
|
||||||
from .identity import workspace_uuid_from_instance_id
|
|
||||||
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||||
from .repository import WorkspaceRepository
|
from .repository import WorkspaceRepository
|
||||||
|
|
||||||
@@ -284,7 +282,6 @@ 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)
|
||||||
@@ -452,7 +449,6 @@ class WorkspaceService:
|
|||||||
account_uuid=account_uuid,
|
account_uuid=account_uuid,
|
||||||
role=MembershipRole.OWNER.value,
|
role=MembershipRole.OWNER.value,
|
||||||
status=MembershipStatus.ACTIVE.value,
|
status=MembershipStatus.ACTIVE.value,
|
||||||
source=MembershipSource.LOCAL.value,
|
|
||||||
joined_at=joined_at,
|
joined_at=joined_at,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
)
|
)
|
||||||
@@ -460,7 +456,6 @@ class WorkspaceService:
|
|||||||
else:
|
else:
|
||||||
membership.role = MembershipRole.OWNER.value
|
membership.role = MembershipRole.OWNER.value
|
||||||
membership.status = MembershipStatus.ACTIVE.value
|
membership.status = MembershipStatus.ACTIVE.value
|
||||||
membership.source = MembershipSource.LOCAL.value
|
|
||||||
membership.joined_at = membership.joined_at or joined_at
|
membership.joined_at = membership.joined_at or joined_at
|
||||||
|
|
||||||
if workspace.created_by_account_uuid is None:
|
if workspace.created_by_account_uuid is None:
|
||||||
@@ -502,7 +497,7 @@ class WorkspaceService:
|
|||||||
created_by_account_uuid: str | None = None,
|
created_by_account_uuid: str | None = None,
|
||||||
) -> Workspace:
|
) -> Workspace:
|
||||||
return Workspace(
|
return Workspace(
|
||||||
uuid=workspace_uuid_from_instance_id(self.instance_uuid),
|
uuid=str(uuid.uuid4()),
|
||||||
instance_uuid=self.instance_uuid,
|
instance_uuid=self.instance_uuid,
|
||||||
name=name,
|
name=name,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|||||||
@@ -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, 3072]
|
allowed_dimensions: [384, 512, 768, 1024, 1536]
|
||||||
host: '127.0.0.1'
|
host: '127.0.0.1'
|
||||||
port: 5433
|
port: 5433
|
||||||
database: 'langbot'
|
database: 'langbot'
|
||||||
@@ -245,8 +245,6 @@ storage:
|
|||||||
max_concurrency: 16
|
max_concurrency: 16
|
||||||
plugin:
|
plugin:
|
||||||
enable: true
|
enable: true
|
||||||
# Maximum time for the Runtime transport, handshake, and desired-state replay.
|
|
||||||
connect_timeout_seconds: 180.0
|
|
||||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||||
enable_marketplace: true
|
enable_marketplace: true
|
||||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||||
|
|||||||
@@ -106,12 +106,7 @@ 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(
|
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
|
||||||
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()
|
||||||
|
|
||||||
@@ -237,9 +232,8 @@ 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()
|
application.plugin_connector.get_debug_info.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ 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
|
||||||
|
|
||||||
@@ -306,34 +304,12 @@ 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,6 +333,7 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
|
|||||||
assert Permission.RESOURCE_MANAGE.value in permissions
|
assert 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,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -15,7 +14,6 @@ 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
|
||||||
@@ -60,12 +58,6 @@ 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}'
|
||||||
)
|
)
|
||||||
@@ -242,11 +234,7 @@ 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(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
|
||||||
'oauth-code',
|
|
||||||
[WORKSPACE_UUID],
|
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -284,10 +272,9 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
'/api/v1/user/space-credits',
|
'/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 payload['data'] == {
|
assert (await response.get_json())['data'] == {
|
||||||
'credits': 25000,
|
'credits': 25000,
|
||||||
'owner_space_bound': True,
|
'owner_space_bound': True,
|
||||||
'is_workspace_owner': True,
|
'is_workspace_owner': True,
|
||||||
@@ -295,31 +282,6 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
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
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ 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',
|
||||||
@@ -264,14 +263,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert member_current['membership']['role'] == 'viewer'
|
assert member_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),
|
||||||
@@ -281,31 +272,6 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_membership_source_migration_backfills_existing_rows_as_local_and_enforces_constraint(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "membership-source.db"}')
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE workspace_memberships (
|
|
||||||
uuid VARCHAR(36) PRIMARY KEY,
|
|
||||||
workspace_uuid VARCHAR(36) NOT NULL,
|
|
||||||
account_uuid VARCHAR(36) NOT NULL,
|
|
||||||
role VARCHAR(32) NOT NULL,
|
|
||||||
status VARCHAR(32) NOT NULL,
|
|
||||||
projection_revision BIGINT NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
"""
|
|
||||||
INSERT INTO workspace_memberships
|
|
||||||
(uuid, workspace_uuid, account_uuid, role, status, projection_revision)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-4000-8000-000000000001', 'workspace', 'local-account',
|
|
||||||
'viewer', 'active', 0),
|
|
||||||
('00000000-0000-4000-8000-000000000002', 'workspace', 'cloud-account',
|
|
||||||
'viewer', 'active', 0)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await run_alembic_stamp(engine, '0019_single_workspace_owner')
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
rows = (
|
|
||||||
await connection.execute(sa.text('SELECT uuid, source FROM workspace_memberships ORDER BY uuid'))
|
|
||||||
).all()
|
|
||||||
columns = await connection.run_sync(
|
|
||||||
lambda sync_connection: {
|
|
||||||
column['name']: column
|
|
||||||
for column in sa.inspect(sync_connection).get_columns('workspace_memberships')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert rows == [
|
|
||||||
('00000000-0000-4000-8000-000000000001', 'local'),
|
|
||||||
('00000000-0000-4000-8000-000000000002', 'local'),
|
|
||||||
]
|
|
||||||
assert columns['source']['nullable'] is False
|
|
||||||
|
|
||||||
with pytest.raises(sa.exc.IntegrityError):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text("UPDATE workspace_memberships SET source = 'guessed-from-user-source'")
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -95,18 +95,6 @@ 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() == '0020_membership_source'
|
|
||||||
|
|
||||||
@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,32 +85,6 @@ 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, 3072],
|
'allowed_dimensions': [384, 512, 768, 1024, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.user import User
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
|
|
||||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
workspace_uuid = '00000000-0000-4000-8000-000000000001'
|
|
||||||
creator_uuid = '00000000-0000-4000-8000-000000000010'
|
|
||||||
promoted_uuid = '00000000-0000-4000-8000-000000000020'
|
|
||||||
async with session_factory() as session:
|
|
||||||
session.add_all(
|
|
||||||
[
|
|
||||||
User(
|
|
||||||
uuid=creator_uuid,
|
|
||||||
user='creator@example.test',
|
|
||||||
normalized_email='creator@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
User(
|
|
||||||
uuid=promoted_uuid,
|
|
||||||
user='promoted@example.test',
|
|
||||||
normalized_email='promoted@example.test',
|
|
||||||
password='hash',
|
|
||||||
account_type='local',
|
|
||||||
),
|
|
||||||
Workspace(
|
|
||||||
uuid=workspace_uuid,
|
|
||||||
instance_uuid='instance-test',
|
|
||||||
name='Workspace',
|
|
||||||
slug='workspace',
|
|
||||||
type='team',
|
|
||||||
status='active',
|
|
||||||
source='local',
|
|
||||||
created_by_account_uuid=creator_uuid,
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000100',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=creator_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='00000000-0000-4000-8000-000000000200',
|
|
||||||
workspace_uuid=workspace_uuid,
|
|
||||||
account_uuid=promoted_uuid,
|
|
||||||
role='owner',
|
|
||||||
status='active',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await run_alembic_stamp(engine, '0018_merge_launch_replay')
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
roles = dict(
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sa.text(
|
|
||||||
'SELECT account_uuid, role FROM workspace_memberships '
|
|
||||||
'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
|
|
||||||
),
|
|
||||||
{'workspace_uuid': workspace_uuid},
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
|
|
||||||
indexes = await connection.run_sync(
|
|
||||||
lambda sync_connection: {
|
|
||||||
index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert 'uq_workspace_memberships_one_active_owner' in indexes
|
|
||||||
|
|
||||||
with pytest.raises(sa.exc.IntegrityError):
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.execute(
|
|
||||||
sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
|
|
||||||
{'account_uuid': promoted_uuid},
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -22,7 +22,6 @@ from langbot.pkg.persistence.alembic_runner import (
|
|||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
from langbot.pkg.workspace.collaboration import normalize_email
|
from langbot.pkg.workspace.collaboration import normalize_email
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||||
@@ -110,7 +109,6 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
|
|||||||
.mappings()
|
.mappings()
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
|
|
||||||
assert workspace['instance_uuid'] == 'instance_migration_test'
|
assert workspace['instance_uuid'] == 'instance_migration_test'
|
||||||
assert workspace['slug'] == 'default'
|
assert workspace['slug'] == 'default'
|
||||||
assert workspace['status'] == 'active'
|
assert workspace['status'] == 'active'
|
||||||
@@ -151,53 +149,6 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_
|
|||||||
assert workspace_uuid_after == workspace_uuid_before
|
assert workspace_uuid_after == workspace_uuid_before
|
||||||
|
|
||||||
|
|
||||||
async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
|
||||||
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
|
||||||
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
|
|
||||||
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
|
|
||||||
schema = sa.MetaData()
|
|
||||||
sa.Table(
|
|
||||||
'metadata',
|
|
||||||
schema,
|
|
||||||
sa.Column('key', sa.String(255), primary_key=True),
|
|
||||||
sa.Column('value', sa.String(255)),
|
|
||||||
)
|
|
||||||
sa.Table(
|
|
||||||
'workspaces',
|
|
||||||
schema,
|
|
||||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
|
||||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
|
||||||
sa.Column('slug', sa.String(255), nullable=False),
|
|
||||||
sa.Column('source', sa.String(32), nullable=False),
|
|
||||||
)
|
|
||||||
sa.Table(
|
|
||||||
'tenant_rows',
|
|
||||||
schema,
|
|
||||||
sa.Column('id', sa.Integer, primary_key=True),
|
|
||||||
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
|
|
||||||
)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(schema.create_all)
|
|
||||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
|
||||||
await conn.execute(
|
|
||||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
|
||||||
{'uuid': old_workspace_uuid},
|
|
||||||
)
|
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
|
||||||
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
|
||||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -411,47 +362,6 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
|
||||||
try:
|
|
||||||
await _create_legacy_schema(engine)
|
|
||||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
||||||
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
|
||||||
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
|
|
||||||
assert old_uuid
|
|
||||||
assert instance_uuid
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
|
||||||
),
|
|
||||||
{'workspace_uuid': old_uuid},
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
|
||||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
||||||
),
|
|
||||||
{'workspace_uuid': old_uuid},
|
|
||||||
)
|
|
||||||
|
|
||||||
await run_alembic_upgrade(engine, 'head')
|
|
||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
|
||||||
assert await conn.scalar(
|
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
|
||||||
) == expected_uuid
|
|
||||||
assert await conn.scalar(
|
|
||||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
|
||||||
) == expected_uuid
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ 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_delete_workspace_or_link_billing():
|
def test_admin_cannot_transfer_owner_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)
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Cloud Runtime write protection for the managed LangBot Models catalog."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from langbot.pkg.api.http.service import model as model_service_module
|
|
||||||
from langbot.pkg.api.http.service.model import (
|
|
||||||
EmbeddingModelsService,
|
|
||||||
LLMModelsService,
|
|
||||||
RerankModelsService,
|
|
||||||
_assert_cloud_managed_provider_mutable,
|
|
||||||
)
|
|
||||||
from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
|
||||||
|
|
||||||
|
|
||||||
WORKSPACE = 'workspace-a'
|
|
||||||
PROVIDER = 'managed-provider'
|
|
||||||
MODEL = 'managed-model'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None:
|
|
||||||
async def managed_provider(_ap, _context, provider_uuid):
|
|
||||||
assert provider_uuid == PROVIDER
|
|
||||||
return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}
|
|
||||||
|
|
||||||
monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
application.persistence_mgr.mode.value = 'normal'
|
|
||||||
await _assert_cloud_managed_provider_mutable(
|
|
||||||
application,
|
|
||||||
WORKSPACE,
|
|
||||||
PROVIDER,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'create_method', 'model_data'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}),
|
|
||||||
(EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}),
|
|
||||||
(RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_creation_under_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
create_method: str,
|
|
||||||
model_data: dict,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(
|
|
||||||
persistence_mgr=SimpleNamespace(),
|
|
||||||
provider_service=SimpleNamespace(
|
|
||||||
get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER})
|
|
||||||
),
|
|
||||||
model_mgr=None,
|
|
||||||
)
|
|
||||||
service = service_type(application)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, create_method)(WORKSPACE, model_data)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('service_type', 'get_method', 'write_method', 'payload'),
|
|
||||||
[
|
|
||||||
(LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}),
|
|
||||||
(LLMModelsService, 'get_llm_model', 'delete_llm_model', None),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}),
|
|
||||||
(EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}),
|
|
||||||
(RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_model_types_reject_update_and_delete_for_managed_provider(
|
|
||||||
monkeypatch,
|
|
||||||
service_type,
|
|
||||||
get_method: str,
|
|
||||||
write_method: str,
|
|
||||||
payload: dict | None,
|
|
||||||
) -> None:
|
|
||||||
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
|
||||||
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
|
||||||
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
|
||||||
service = service_type(application)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
service,
|
|
||||||
get_method,
|
|
||||||
AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload)
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
|
||||||
await getattr(service, write_method)(*args)
|
|
||||||
|
|
||||||
guard.assert_awaited_once()
|
|
||||||
@@ -25,7 +25,6 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
|||||||
pytestmark = pytest.mark.asyncio
|
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(
|
||||||
@@ -1006,56 +1005,3 @@ 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,7 +25,6 @@ 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
|
||||||
@@ -574,20 +573,10 @@ 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(
|
result = await service.exchange_oauth_code('auth_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."""
|
||||||
|
|||||||
@@ -124,37 +124,24 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uow(plugin_router_cls):
|
async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls):
|
||||||
scopes = []
|
scopes = []
|
||||||
active_scope = None
|
|
||||||
|
|
||||||
transaction_active = False
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def tenant_scope(workspace_uuid):
|
async def tenant_uow(workspace_uuid):
|
||||||
nonlocal active_scope
|
|
||||||
scopes.append(workspace_uuid)
|
scopes.append(workspace_uuid)
|
||||||
active_scope = workspace_uuid
|
yield
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
active_scope = None
|
|
||||||
|
|
||||||
connector = SimpleNamespace(
|
connector = SimpleNamespace(
|
||||||
require_workspace_context=AsyncMock(side_effect=lambda context: context),
|
require_workspace_context=AsyncMock(side_effect=lambda context: context),
|
||||||
)
|
)
|
||||||
|
operation = AsyncMock(return_value='done')
|
||||||
async def operation():
|
|
||||||
assert active_scope == CONTEXT.workspace_uuid
|
|
||||||
assert transaction_active is False
|
|
||||||
return 'done'
|
|
||||||
|
|
||||||
router = object.__new__(plugin_router_cls)
|
router = object.__new__(plugin_router_cls)
|
||||||
router.ap = SimpleNamespace(
|
router.ap = SimpleNamespace(
|
||||||
plugin_connector=connector,
|
plugin_connector=connector,
|
||||||
persistence_mgr=SimpleNamespace(
|
persistence_mgr=SimpleNamespace(
|
||||||
mode=SimpleNamespace(value='cloud_runtime'),
|
mode=SimpleNamespace(value='cloud_runtime'),
|
||||||
tenant_scope=tenant_scope,
|
tenant_uow=tenant_uow,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,3 +150,4 @@ async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uo
|
|||||||
assert result == 'done'
|
assert result == 'done'
|
||||||
assert scopes == [CONTEXT.workspace_uuid]
|
assert scopes == [CONTEXT.workspace_uuid]
|
||||||
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
||||||
|
operation.assert_awaited_once()
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ 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(
|
||||||
@@ -83,7 +79,6 @@ 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',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +103,7 @@ def _cloud_config() -> dict:
|
|||||||
'use': 'pgvector',
|
'use': 'pgvector',
|
||||||
'pgvector': {
|
'pgvector': {
|
||||||
'use_business_database': True,
|
'use_business_database': True,
|
||||||
'allowed_dimensions': [384, 768, 1536, 3072],
|
'allowed_dimensions': [384, 768, 1536],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': False}},
|
'mcp': {'stdio': {'enabled': False}},
|
||||||
@@ -216,6 +211,7 @@ 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'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -232,23 +228,10 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_runtime_allows_explicitly_disabled_box():
|
|
||||||
config = _cloud_config()
|
|
||||||
config['box']['enabled'] = False
|
|
||||||
|
|
||||||
deployment = await resolve_deployment(
|
|
||||||
instance_uuid='instance-a',
|
|
||||||
instance_config=config,
|
|
||||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
|
||||||
now=1_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(deployment, VerifiedCloudDeployment)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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,39 +181,6 @@ 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()
|
||||||
@@ -1056,7 +1023,7 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context):
|
|||||||
await service.initialize()
|
await service.initialize()
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_revision_zero_membership_is_adopted(projection_context):
|
async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
@@ -1067,125 +1034,29 @@ async def test_directory_revision_zero_membership_is_adopted(projection_context)
|
|||||||
membership.role = 'viewer'
|
membership.role = 'viewer'
|
||||||
membership.status = 'active'
|
membership.status = 'active'
|
||||||
membership.projection_revision = 0
|
membership.projection_revision = 0
|
||||||
|
|
||||||
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
|
||||||
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
|
||||||
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
assert membership.source == 'cloud_projection'
|
|
||||||
assert membership.role == 'owner'
|
|
||||||
assert membership.status == 'removed'
|
|
||||||
assert membership.projection_revision == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_revision_zero_membership_omitted_from_snapshot_is_removed(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
historical_account_uuid = '20000000-0000-0000-0000-000000000099'
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
session.add(
|
|
||||||
User(
|
|
||||||
uuid=historical_account_uuid,
|
|
||||||
user='Historical Space Member',
|
|
||||||
normalized_email='historical@example.com',
|
|
||||||
password='',
|
|
||||||
status='active',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=1,
|
|
||||||
account_type='space',
|
|
||||||
space_account_uuid=historical_account_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.add(
|
session.add(
|
||||||
WorkspaceMembership(
|
WorkspaceMembership(
|
||||||
uuid=SECOND_MEMBERSHIP_UUID,
|
uuid=SECOND_MEMBERSHIP_UUID,
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
workspace_uuid=WORKSPACE_UUID,
|
||||||
account_uuid=historical_account_uuid,
|
account_uuid='20000000-0000-0000-0000-000000000099',
|
||||||
role='viewer',
|
role='viewer',
|
||||||
status='active',
|
status='active',
|
||||||
source='cloud_projection',
|
|
||||||
joined_at=membership.joined_at,
|
joined_at=membership.joined_at,
|
||||||
projection_revision=0,
|
projection_revision=0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await service.apply_snapshot(_snapshot(2))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
historical = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
|
||||||
assert historical.status == 'removed'
|
|
||||||
assert historical.projection_revision == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_account_core_invitation_membership_survives_directory_omission(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
invited_account_uuid = '20000000-0000-0000-0000-000000000098'
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
projected_membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
session.add(
|
|
||||||
User(
|
|
||||||
uuid=invited_account_uuid,
|
|
||||||
user='Invited Cloud Account',
|
|
||||||
normalized_email='invited-cloud@example.com',
|
|
||||||
password='',
|
|
||||||
status='active',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=1,
|
|
||||||
account_type='space',
|
|
||||||
space_account_uuid=invited_account_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.add(
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid=SECOND_MEMBERSHIP_UUID,
|
|
||||||
workspace_uuid=WORKSPACE_UUID,
|
|
||||||
account_uuid=invited_account_uuid,
|
|
||||||
role='viewer',
|
|
||||||
status='active',
|
|
||||||
source='local',
|
|
||||||
joined_at=projected_membership.joined_at,
|
|
||||||
projection_revision=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.apply_snapshot(_snapshot(2))
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
|
||||||
assert membership.source == 'local'
|
|
||||||
assert membership.status == 'active'
|
|
||||||
assert membership.projection_revision == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_directory_does_not_adopt_local_membership_with_different_uuid_for_same_cloud_account(projection_context):
|
|
||||||
application, session_factory = projection_context
|
|
||||||
service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
|
|
||||||
await service.initialize()
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
|
|
||||||
membership.uuid = SECOND_MEMBERSHIP_UUID
|
|
||||||
membership.source = 'local'
|
|
||||||
membership.projection_revision = 0
|
|
||||||
|
|
||||||
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
|
||||||
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
|
||||||
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
|
||||||
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
|
memberships = {
|
||||||
assert membership.source == 'local'
|
membership.uuid: membership
|
||||||
assert membership.role == 'developer'
|
for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all()
|
||||||
assert membership.status == 'active'
|
}
|
||||||
assert membership.projection_revision == 0
|
assert memberships[MEMBERSHIP_UUID].role == 'viewer'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active'
|
||||||
|
assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from langbot.pkg.cloud.model_catalog import (
|
|
||||||
CloudModelCatalogSnapshot,
|
|
||||||
CloudModelCatalogSyncService,
|
|
||||||
system_model_uuid,
|
|
||||||
system_provider_uuid,
|
|
||||||
)
|
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
|
||||||
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider
|
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
|
||||||
INSTANCE_UUID = 'instance-model-catalog'
|
|
||||||
WORKSPACE_A = '00000000-0000-4000-8000-000000000001'
|
|
||||||
WORKSPACE_B = '00000000-0000-4000-8000-000000000002'
|
|
||||||
OWNER_A = '10000000-0000-4000-8000-000000000001'
|
|
||||||
OWNER_B = '10000000-0000-4000-8000-000000000002'
|
|
||||||
|
|
||||||
|
|
||||||
class _CatalogProvider:
|
|
||||||
def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None:
|
|
||||||
self.snapshot = snapshot
|
|
||||||
|
|
||||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
|
||||||
assert instance_uuid == INSTANCE_UUID
|
|
||||||
return self.snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(
|
|
||||||
*,
|
|
||||||
key_a: str | None = 'owner-a-key',
|
|
||||||
model_id: str = 'gpt-test',
|
|
||||||
include_embedding: bool = True,
|
|
||||||
) -> CloudModelCatalogSnapshot:
|
|
||||||
models = [
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-chat',
|
|
||||||
'model_id': model_id,
|
|
||||||
'category': 'chat',
|
|
||||||
'llm_abilities': ['chat', 'vision'],
|
|
||||||
'is_featured': True,
|
|
||||||
'featured_order': 7,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if include_embedding:
|
|
||||||
models.append(
|
|
||||||
{
|
|
||||||
'uuid': 'upstream-embedding',
|
|
||||||
'model_id': 'embedding-test',
|
|
||||||
'category': 'embedding',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return CloudModelCatalogSnapshot.model_validate(
|
|
||||||
{
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'generated_at': datetime.now(UTC),
|
|
||||||
'base_url': 'https://api.langbot.cloud/v1/',
|
|
||||||
'models': models,
|
|
||||||
'workspaces': [
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_A,
|
|
||||||
'owner_account_uuid': OWNER_A,
|
|
||||||
'api_key': key_a,
|
|
||||||
'credits': 25000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workspace_uuid': WORKSPACE_B,
|
|
||||||
'owner_account_uuid': OWNER_B,
|
|
||||||
'api_key': 'owner-b-key',
|
|
||||||
'credits': 5000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None:
|
|
||||||
payload = _snapshot().model_dump(mode='json')
|
|
||||||
payload['models'][0]['llm_abilities'] = None
|
|
||||||
|
|
||||||
snapshot = CloudModelCatalogSnapshot.model_validate(payload)
|
|
||||||
|
|
||||||
assert snapshot.models[0].llm_abilities == ()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None:
|
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}')
|
|
||||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
||||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
||||||
bindings = [
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1),
|
|
||||||
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
|
||||||
]
|
|
||||||
workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings))
|
|
||||||
reload_counter = _AsyncCounter()
|
|
||||||
runtime_reload = SimpleNamespace(load_models_from_db=reload_counter)
|
|
||||||
app = SimpleNamespace(
|
|
||||||
persistence_mgr=manager,
|
|
||||||
workspace_service=workspace_service,
|
|
||||||
model_mgr=runtime_reload,
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
provider = _CatalogProvider(_snapshot())
|
|
||||||
service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(Workspace),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_A,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'A',
|
|
||||||
'slug': 'a',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'uuid': WORKSPACE_B,
|
|
||||||
'instance_uuid': INSTANCE_UUID,
|
|
||||||
'name': 'B',
|
|
||||||
'slug': 'b',
|
|
||||||
'source': 'cloud_projection',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(ModelProvider).values(
|
|
||||||
uuid='custom-provider',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='Custom',
|
|
||||||
requester='openai-chat-completions',
|
|
||||||
base_url='https://custom.example/v1',
|
|
||||||
api_keys=['custom-key'],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.insert(LLMModel).values(
|
|
||||||
uuid='custom-model',
|
|
||||||
workspace_uuid=WORKSPACE_A,
|
|
||||||
name='custom-model',
|
|
||||||
provider_uuid='custom-provider',
|
|
||||||
abilities=['chat'],
|
|
||||||
extra_args={},
|
|
||||||
prefered_ranking=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
first = await service.sync_once()
|
|
||||||
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
|
||||||
assert service.get_workspace_credits(WORKSPACE_B) == 5000
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
providers = (
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(
|
|
||||||
ModelProvider.uuid,
|
|
||||||
ModelProvider.workspace_uuid,
|
|
||||||
ModelProvider.api_keys,
|
|
||||||
).where(ModelProvider.requester == 'space-chat-completions')
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B}
|
|
||||||
assert {item.uuid for item in providers} == {
|
|
||||||
system_provider_uuid(WORKSPACE_A),
|
|
||||||
system_provider_uuid(WORKSPACE_B),
|
|
||||||
}
|
|
||||||
assert {item.workspace_uuid: item.api_keys for item in providers} == {
|
|
||||||
WORKSPACE_A: ['owner-a-key'],
|
|
||||||
WORKSPACE_B: ['owner-b-key'],
|
|
||||||
}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2
|
|
||||||
|
|
||||||
second = await service.sync_once()
|
|
||||||
assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 1
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(
|
|
||||||
key_a='new-owner-key',
|
|
||||||
model_id='gpt-renamed',
|
|
||||||
include_embedding=False,
|
|
||||||
)
|
|
||||||
third = await service.sync_once()
|
|
||||||
assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2}
|
|
||||||
assert reload_counter.calls == 2
|
|
||||||
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == ['new-owner-key']
|
|
||||||
system_model_names = (
|
|
||||||
(
|
|
||||||
await connection.execute(
|
|
||||||
sqlalchemy.select(LLMModel.name).where(
|
|
||||||
LLMModel.provider_uuid.in_(
|
|
||||||
[system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert set(system_model_names) == {'gpt-renamed'}
|
|
||||||
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(ModelProvider)
|
|
||||||
.where(ModelProvider.uuid == 'custom-provider')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await connection.scalar(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count())
|
|
||||||
.select_from(LLMModel)
|
|
||||||
.where(LLMModel.uuid == 'custom-model')
|
|
||||||
)
|
|
||||||
== 1
|
|
||||||
)
|
|
||||||
|
|
||||||
provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False)
|
|
||||||
fourth = await service.sync_once()
|
|
||||||
assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0}
|
|
||||||
assert reload_counter.calls == 3
|
|
||||||
async with engine.connect() as connection:
|
|
||||||
provider_a_keys = await connection.scalar(
|
|
||||||
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
|
||||||
)
|
|
||||||
assert provider_a_keys == []
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None:
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A)
|
|
||||||
assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B)
|
|
||||||
assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream')
|
|
||||||
snapshot = _snapshot()
|
|
||||||
assert 'owner-a-key' not in repr(snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_must_cover_every_active_workspace() -> None:
|
|
||||||
snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]})
|
|
||||||
app = SimpleNamespace(
|
|
||||||
workspace_service=SimpleNamespace(
|
|
||||||
list_active_execution_bindings=lambda: _async_value(
|
|
||||||
[SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
logger=logging.getLogger(__name__),
|
|
||||||
)
|
|
||||||
service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID)
|
|
||||||
with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'):
|
|
||||||
await service.sync_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def 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
|
|
||||||
@@ -319,7 +319,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
cfg = {
|
cfg = {
|
||||||
'plugin': {
|
'plugin': {
|
||||||
'connect_timeout_seconds': 30.0,
|
|
||||||
'worker': {
|
'worker': {
|
||||||
'max_cpus': 1.0,
|
'max_cpus': 1.0,
|
||||||
'max_memory_mb': 512,
|
'max_memory_mb': 512,
|
||||||
@@ -330,12 +329,11 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
'restart_failure_threshold': 8,
|
'restart_failure_threshold': 8,
|
||||||
'restart_failure_window_seconds': 30.0,
|
'restart_failure_window_seconds': 30.0,
|
||||||
'restart_circuit_open_seconds': 60.0,
|
'restart_circuit_open_seconds': 60.0,
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
'mcp': {'stdio': {'enabled': True}},
|
'mcp': {'stdio': {'enabled': True}},
|
||||||
}
|
}
|
||||||
env = {
|
env = {
|
||||||
'PLUGIN__CONNECT_TIMEOUT_SECONDS': '180',
|
|
||||||
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
'PLUGIN__WORKER__MAX_CPUS': '2.5',
|
||||||
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
|
||||||
'PLUGIN__WORKER__MAX_PIDS': '64',
|
'PLUGIN__WORKER__MAX_PIDS': '64',
|
||||||
@@ -351,7 +349,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
result = load_config._apply_env_overrides_to_config(cfg)
|
result = load_config._apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
assert result['plugin']['connect_timeout_seconds'] == 180.0
|
|
||||||
assert result['plugin']['worker'] == {
|
assert result['plugin']['worker'] == {
|
||||||
'max_cpus': 2.5,
|
'max_cpus': 2.5,
|
||||||
'max_memory_mb': 1024,
|
'max_memory_mb': 1024,
|
||||||
@@ -396,14 +393,6 @@ class TestApplyEnvOverridesToConfig:
|
|||||||
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
|
||||||
assert result['mcp']['stdio']['enabled'] is False
|
assert result['mcp']['stdio']['enabled'] is False
|
||||||
|
|
||||||
def test_runtime_policy_defaults_add_typed_plugin_connect_timeout(self):
|
|
||||||
load_config = get_load_config_module()
|
|
||||||
|
|
||||||
completed = load_config._complete_runtime_policy_defaults({'plugin': {'enable': True}})
|
|
||||||
|
|
||||||
assert completed['plugin']['connect_timeout_seconds'] == 180.0
|
|
||||||
assert isinstance(completed['plugin']['connect_timeout_seconds'], float)
|
|
||||||
|
|
||||||
def test_webhook_prefix_override(self):
|
def test_webhook_prefix_override(self):
|
||||||
"""Test overriding webhook_prefix via environment variable."""
|
"""Test overriding webhook_prefix via environment variable."""
|
||||||
load_config = get_load_config_module()
|
load_config = get_load_config_module()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
from pgvector.sqlalchemy import 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,13 +961,11 @@ 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,7 +11,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -144,31 +143,3 @@ 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,7 +1,6 @@
|
|||||||
"""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
|
||||||
|
|
||||||
@@ -205,48 +204,6 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
|||||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
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,13 +612,8 @@ 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(execution_context)
|
result = await connector.get_debug_info()
|
||||||
|
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
|||||||
@@ -132,49 +132,6 @@ async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
|
|||||||
await connector.aclose()
|
await connector.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_connect_timeout_is_rejected_before_transport_startup(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
):
|
|
||||||
connector = make_connector()
|
|
||||||
connector.ap.instance_config.data['plugin']['connect_timeout_seconds'] = 0
|
|
||||||
stdio_controller = Mock()
|
|
||||||
websocket_controller = Mock()
|
|
||||||
create_task = Mock()
|
|
||||||
get_platform = Mock(return_value='linux')
|
|
||||||
use_websocket = Mock(return_value=False)
|
|
||||||
connector._start_runtime_subprocess = AsyncMock()
|
|
||||||
monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
|
|
||||||
monkeypatch.setattr(connector_module.asyncio, 'create_task', create_task)
|
|
||||||
monkeypatch.setattr(connector_module.platform, 'get_platform', get_platform)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
connector_module.platform,
|
|
||||||
'use_websocket_to_connect_plugin_runtime',
|
|
||||||
use_websocket,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
connector_module.stdio_client_controller,
|
|
||||||
'StdioClientController',
|
|
||||||
stdio_controller,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
connector_module.ws_client_controller,
|
|
||||||
'WebSocketClientController',
|
|
||||||
websocket_controller,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='plugin.connect_timeout_seconds'):
|
|
||||||
await connector.initialize()
|
|
||||||
|
|
||||||
get_platform.assert_not_called()
|
|
||||||
use_websocket.assert_not_called()
|
|
||||||
stdio_controller.assert_not_called()
|
|
||||||
websocket_controller.assert_not_called()
|
|
||||||
connector._start_runtime_subprocess.assert_not_awaited()
|
|
||||||
create_task.assert_not_called()
|
|
||||||
assert connector._transport_task is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_disconnect_notifies_once_and_clears_handler(
|
async def test_runtime_disconnect_notifies_once_and_clears_handler(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -325,11 +282,12 @@ 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_are_empty_when_secret_is_unset(monkeypatch):
|
def test_external_runtime_control_headers_require_strong_secret(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()
|
||||||
|
|
||||||
assert connector._control_headers(allow_generate=False) == {}
|
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
|
||||||
|
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):
|
||||||
|
|||||||
@@ -153,31 +153,6 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
|||||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shared_reconcile_logs_workspace_installation_counts_and_elapsed_time():
|
|
||||||
binding_a = execution_binding('workspace-a')
|
|
||||||
binding_b = execution_binding('workspace-b')
|
|
||||||
setting_a = plugin_setting('01', 'a' * 64)
|
|
||||||
setting_b = plugin_setting('02', 'b' * 64)
|
|
||||||
connector = shared_connector(
|
|
||||||
[[binding_a, binding_b]],
|
|
||||||
{'workspace-a': [setting_a], 'workspace-b': [setting_b]},
|
|
||||||
)
|
|
||||||
connector.handler = runtime_handler()
|
|
||||||
await connector._prepare_connected_runtime()
|
|
||||||
|
|
||||||
matching_calls = [
|
|
||||||
call
|
|
||||||
for call in connector.ap.logger.info.call_args_list
|
|
||||||
if call.args
|
|
||||||
and call.args[0]
|
|
||||||
== 'Shared plugin runtime reconcile completed: workspaces=%d desired_installations=%d elapsed_seconds=%.3f'
|
|
||||||
]
|
|
||||||
assert len(matching_calls) == 1
|
|
||||||
assert matching_calls[0].args[1:3] == (2, 2)
|
|
||||||
assert matching_calls[0].args[3] >= 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
|
async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
|
||||||
package = b'local-lbpkg-bytes'
|
package = b'local-lbpkg-bytes'
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ Tests cover:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
def get_connector_module():
|
def get_connector_module():
|
||||||
@@ -61,28 +60,3 @@ def test_runtime_id_is_stable_across_core_restarts(monkeypatch):
|
|||||||
monkeypatch.setattr(connector.constants, 'instance_id', 'instance-a')
|
monkeypatch.setattr(connector.constants, 'instance_id', 'instance-a')
|
||||||
|
|
||||||
assert connector.PluginRuntimeConnector._build_runtime_id() == 'instance-a:plugin-runtime'
|
assert connector.PluginRuntimeConnector._build_runtime_id() == 'instance-a:plugin-runtime'
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_connect_timeout_defaults_to_three_minutes():
|
|
||||||
connector = get_connector_module()
|
|
||||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout({}) == 180.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_connect_timeout_reads_typed_plugin_config():
|
|
||||||
connector = get_connector_module()
|
|
||||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout({'connect_timeout_seconds': 45.5}) == 45.5
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('value', [True, False, None, 0, -1, float('nan'), float('inf'), '180', object()])
|
|
||||||
def test_runtime_connect_timeout_rejects_invalid_values(value):
|
|
||||||
connector = get_connector_module()
|
|
||||||
with pytest.raises(ValueError, match='plugin.connect_timeout_seconds'):
|
|
||||||
connector.PluginRuntimeConnector._runtime_connect_timeout({'connect_timeout_seconds': value})
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_connect_timeout_error_displays_actual_seconds():
|
|
||||||
connector = get_connector_module()
|
|
||||||
|
|
||||||
assert connector.PluginRuntimeConnector._runtime_connect_timeout_error(45.5) == (
|
|
||||||
'Plugin runtime did not become ready within 45.5 seconds'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
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
|
||||||
@@ -15,12 +14,6 @@ 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()
|
||||||
@@ -64,17 +57,13 @@ def make_app():
|
|||||||
|
|
||||||
class TestBuildHeartbeatPayload:
|
class TestBuildHeartbeatPayload:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_payload_shape(self, monkeypatch):
|
async def test_payload_shape(self):
|
||||||
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)
|
||||||
|
|
||||||
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['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']
|
||||||
@@ -97,7 +86,7 @@ class TestBuildHeartbeatPayload:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_payload_is_json_serializable(self):
|
async def test_payload_is_json_serializable(self):
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||||
json.dumps(payload)
|
json.dumps(payload)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -105,13 +94,12 @@ class TestBuildHeartbeatPayload:
|
|||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
ap = make_app()
|
ap = make_app()
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
|
||||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||||
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, monkeypatch):
|
async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
|
||||||
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(
|
||||||
@@ -123,10 +111,8 @@ class TestBuildHeartbeatPayload:
|
|||||||
('instance-a', 'workspace-a', 'pipeline-b'): object(),
|
('instance-a', 'workspace-a', 'pipeline-b'): object(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
adapter_a = type('WorkspaceAAdapter', (), {})()
|
|
||||||
adapter_b = type('WorkspaceBAdapter', (), {})()
|
|
||||||
ap.platform_mgr._bots_by_key = {
|
ap.platform_mgr._bots_by_key = {
|
||||||
('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a),
|
('instance-a', 'workspace-a', 'bot-a'): object(),
|
||||||
}
|
}
|
||||||
ap.tool_mgr = SimpleNamespace(
|
ap.tool_mgr = SimpleNamespace(
|
||||||
mcp_tool_loader=SimpleNamespace(
|
mcp_tool_loader=SimpleNamespace(
|
||||||
@@ -143,54 +129,35 @@ class TestBuildHeartbeatPayload:
|
|||||||
ap.plugin_connector._workspace_installations = {
|
ap.plugin_connector._workspace_installations = {
|
||||||
'workspace-a': {'plugin-a', 'plugin-b'},
|
'workspace-a': {'plugin-a', 'plugin-b'},
|
||||||
}
|
}
|
||||||
ap.skill_mgr._skills_by_scope = {
|
|
||||||
('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}},
|
|
||||||
('instance-a', 'workspace-b', 1): {'skill-c': {}},
|
|
||||||
}
|
|
||||||
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',
|
|
||||||
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(
|
|
||||||
enable=True, adapter=adapter_b
|
|
||||||
)
|
)
|
||||||
|
|
||||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||||
|
|
||||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
features = payload['features']
|
||||||
assert all(payload['instance_id'] for payload in payloads)
|
assert features['pipeline_count'] == 2
|
||||||
assert payloads[0]['workspace_create_ts'] == 0
|
assert features['mcp_server_count'] == 3
|
||||||
assert payloads[1]['workspace_create_ts'] == 1785801600
|
assert features['knowledge_base_count'] == 1
|
||||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
assert features['bot_count'] == 1
|
||||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
assert features['workspace_resources'] == [
|
||||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
{
|
||||||
assert by_workspace['workspace-a']['knowledge_base_count'] == 1
|
'workspace_uuid': 'workspace-a',
|
||||||
assert by_workspace['workspace-a']['bot_count'] == 1
|
'bot_count': 1,
|
||||||
assert by_workspace['workspace-a']['plugin_count'] == 2
|
'pipeline_count': 2,
|
||||||
assert by_workspace['workspace-a']['extension_count'] == 5
|
'knowledge_base_count': 1,
|
||||||
assert by_workspace['workspace-a']['skill_count'] == 2
|
'plugin_count': 2,
|
||||||
assert by_workspace['workspace-a']['execution_generation'] == 7
|
'mcp_server_count': 3,
|
||||||
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
|
'extension_count': 5,
|
||||||
assert by_workspace['workspace-b']['bot_count'] == 1
|
}
|
||||||
assert by_workspace['workspace-b']['pipeline_count'] == 0
|
]
|
||||||
assert by_workspace['workspace-b']['skill_count'] == 1
|
|
||||||
assert by_workspace['workspace-b']['execution_generation'] == 9
|
|
||||||
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
|
|
||||||
assert 'workspace_resources' not in by_workspace['workspace-a']
|
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
ap.workspace_service.list_active_execution_bindings.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_user_content_fields(self):
|
async def test_no_user_content_fields(self):
|
||||||
"""The heartbeat must never carry message content / credentials keys."""
|
"""The heartbeat must never carry message content / credentials keys."""
|
||||||
heartbeat = get_heartbeat_module()
|
heartbeat = get_heartbeat_module()
|
||||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||||
flat = json.dumps(payload).lower()
|
flat = json.dumps(payload).lower()
|
||||||
for forbidden in ('api_key', 'password', 'token', 'message_content'):
|
for forbidden in ('api_key', 'password', 'token', 'message_content'):
|
||||||
assert forbidden not in flat
|
assert forbidden not in flat
|
||||||
|
|||||||
@@ -569,63 +569,6 @@ class TestHTTPScenarios:
|
|||||||
await manager.send({'query_id': 'test'})
|
await manager.send({'query_id': 'test'})
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetryManagedRuntimeAuthentication:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_includes_managed_runtime_token_header(self):
|
|
||||||
telemetry = get_telemetry_module()
|
|
||||||
mock_app = Mock()
|
|
||||||
mock_app.logger = Mock()
|
|
||||||
manager = telemetry.TelemetryManager(mock_app)
|
|
||||||
manager.telemetry_config = {'url': 'https://example.com'}
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def mock_post(url, json, headers):
|
|
||||||
captured['headers'] = headers
|
|
||||||
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
|
|
||||||
|
|
||||||
mock_client = Mock()
|
|
||||||
mock_client.post = mock_post
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
with (
|
|
||||||
patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}),
|
|
||||||
patch.object(httpx, 'AsyncClient', return_value=mock_client),
|
|
||||||
):
|
|
||||||
await manager.send({'event_type': 'instance_heartbeat'})
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
|
|
||||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
|
||||||
|
|
||||||
instance_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
|
||||||
|
|
||||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
|
||||||
assert workspace_uuid_from_instance_id(f'instance_{instance_uuid}') == instance_uuid
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
|
||||||
|
|
||||||
first = workspace_uuid_from_instance_id('instance_migration_test')
|
|
||||||
second = workspace_uuid_from_instance_id('instance_migration_test')
|
|
||||||
|
|
||||||
assert first == second
|
|
||||||
assert str(uuid.UUID(first)) == first
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_telemetry_identity_reports_instance_and_workspace():
|
|
||||||
from langbot.pkg.telemetry.identity import workspace_identity
|
|
||||||
|
|
||||||
identity = workspace_identity(SimpleNamespace(workspace_uuid='workspace-a', instance_uuid='instance-a'))
|
|
||||||
|
|
||||||
assert identity == {
|
|
||||||
'instance_id': 'instance-a',
|
|
||||||
'workspace_uuid': 'workspace-a',
|
|
||||||
}
|
|
||||||
@@ -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, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
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, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
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, 3072],
|
allowed_dimensions=[384, 512, 768, 1024, 1536],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_initialize_pgvector_with_shared_business_database(self):
|
def test_initialize_pgvector_with_shared_business_database(self):
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ async def test_invitation_secret_is_hashed_and_acceptance_is_one_time(collaborat
|
|||||||
membership = await service.accept_invitation(created.token, account.uuid)
|
membership = await service.accept_invitation(created.token, account.uuid)
|
||||||
assert membership.workspace_uuid == workspace.uuid
|
assert membership.workspace_uuid == workspace.uuid
|
||||||
assert membership.role == 'developer'
|
assert membership.role == 'developer'
|
||||||
assert membership.source == 'local'
|
|
||||||
|
|
||||||
with pytest.raises(InvitationUsedError):
|
with pytest.raises(InvitationUsedError):
|
||||||
await service.accept_invitation(created.token, account.uuid)
|
await service.accept_invitation(created.token, account.uuid)
|
||||||
@@ -204,21 +203,20 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
|
|||||||
second_membership,
|
second_membership,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
|
promoted = await service.update_member_role(
|
||||||
await service.update_member_role(
|
workspace.uuid,
|
||||||
workspace.uuid,
|
second.uuid,
|
||||||
second.uuid,
|
'owner',
|
||||||
'owner',
|
owner_membership,
|
||||||
owner_membership,
|
)
|
||||||
)
|
assert promoted.role == 'owner'
|
||||||
|
demoted = await service.update_member_role(
|
||||||
with pytest.raises(LastOwnerError):
|
workspace.uuid,
|
||||||
await service.update_member_role(
|
owner_membership.account_uuid,
|
||||||
workspace.uuid,
|
'admin',
|
||||||
owner_membership.account_uuid,
|
owner_membership,
|
||||||
'admin',
|
)
|
||||||
owner_membership,
|
assert demoted.role == 'admin'
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_selector_requires_membership(collaboration_context):
|
async def test_workspace_selector_requires_membership(collaboration_context):
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from langbot.pkg.workspace import (
|
|||||||
WorkspaceOwnerAlreadyExistsError,
|
WorkspaceOwnerAlreadyExistsError,
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
)
|
)
|
||||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
|
||||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||||
|
|
||||||
|
|
||||||
@@ -114,7 +113,6 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context):
|
|||||||
first = await service.ensure_singleton_workspace()
|
first = await service.ensure_singleton_workspace()
|
||||||
second = await service.ensure_singleton_workspace()
|
second = await service.ensure_singleton_workspace()
|
||||||
|
|
||||||
assert first.uuid == workspace_uuid_from_instance_id('instance_service_test')
|
|
||||||
assert second.uuid == first.uuid
|
assert second.uuid == first.uuid
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
|
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
|
||||||
@@ -153,33 +151,6 @@ async def test_initial_owner_cannot_be_claimed_by_another_account(workspace_test
|
|||||||
).all()
|
).all()
|
||||||
assert len(owners) == 1
|
assert len(owners) == 1
|
||||||
assert owners[0].account_uuid == first_account_uuid
|
assert owners[0].account_uuid == first_account_uuid
|
||||||
assert owners[0].source == 'local'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_claim_initial_owner_reclassifies_existing_membership_as_local(workspace_test_context):
|
|
||||||
service, session_factory = workspace_test_context
|
|
||||||
|
|
||||||
async with session_factory() as session:
|
|
||||||
async with session.begin():
|
|
||||||
account_uuid = await _insert_account(session, 'reclaimed@example.com')
|
|
||||||
workspace = await service.ensure_singleton_workspace(session=session)
|
|
||||||
session.add(
|
|
||||||
WorkspaceMembership(
|
|
||||||
uuid='44444444-4444-4444-8444-444444444444',
|
|
||||||
workspace_uuid=workspace.uuid,
|
|
||||||
account_uuid=account_uuid,
|
|
||||||
role='viewer',
|
|
||||||
status='removed',
|
|
||||||
source='cloud_projection',
|
|
||||||
projection_revision=4,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
membership = await service.claim_initial_owner(account_uuid)
|
|
||||||
|
|
||||||
assert membership.role == 'owner'
|
|
||||||
assert membership.status == 'active'
|
|
||||||
assert membership.source == 'local'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_execution_binding_returns_persisted_generation(workspace_test_context):
|
async def test_execution_binding_returns_persisted_generation(workspace_test_context):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -67,10 +66,6 @@ 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'
|
||||||
@@ -113,31 +108,8 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beginAuthenticatedSession(response.token, response.user);
|
beginAuthenticatedSession(response.token, response.user);
|
||||||
const invitationToken = getPendingInvitationToken();
|
if (getPendingInvitationToken()) {
|
||||||
if (invitationToken) {
|
navigate('/invitations/accept', { replace: true });
|
||||||
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({
|
||||||
@@ -248,28 +220,8 @@ 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');
|
||||||
if (directLaunchFragmentRef.current === null) {
|
const workspaceUuid = searchParams.get('workspace_uuid');
|
||||||
const fragmentParams = new URLSearchParams(
|
const launchAssertion = searchParams.get('launch_assertion');
|
||||||
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');
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ 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';
|
||||||
|
|
||||||
@@ -156,12 +154,6 @@ 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) =>
|
||||||
@@ -352,28 +344,23 @@ function AddExtensionContent() {
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleInstallPlugin = useCallback(
|
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||||
async (plugin: PluginV4) => {
|
setInstallInfo({
|
||||||
if (extensionsReached) return;
|
plugin_author: plugin.author,
|
||||||
setInstallInfo({
|
plugin_name: plugin.name,
|
||||||
plugin_author: plugin.author,
|
plugin_version: plugin.latest_version,
|
||||||
plugin_name: plugin.name,
|
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||||
plugin_version: plugin.latest_version,
|
plugin_description: extractI18nObject(plugin.description) || '',
|
||||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
plugin_icon: plugin.icon || '',
|
||||||
plugin_description: extractI18nObject(plugin.description) || '',
|
});
|
||||||
plugin_icon: plugin.icon || '',
|
setInstallExtensionType(plugin.type || 'plugin');
|
||||||
});
|
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||||
setInstallExtensionType(plugin.type || 'plugin');
|
setInstallError(null);
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
setInstallIconFailed(false);
|
||||||
setInstallError(null);
|
setModalOpen(true);
|
||||||
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
|
||||||
@@ -415,7 +402,6 @@ 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;
|
||||||
@@ -435,15 +421,14 @@ function AddExtensionContent() {
|
|||||||
setSkillUploadPreviewOpen(true);
|
setSkillUploadPreviewOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[extensionsReached, t, setSelectedTaskId],
|
[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>) => {
|
||||||
@@ -470,13 +455,12 @@ 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]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[extensionsReached, uploadFile],
|
[uploadFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleMCPCreated(_serverName: string) {
|
function handleMCPCreated(_serverName: string) {
|
||||||
@@ -506,8 +490,7 @@ function AddExtensionContent() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('limitation.quotaCheckFailed'));
|
// If we can't check, let backend handle it
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -647,11 +630,9 @@ 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(
|
||||||
@@ -683,11 +664,9 @@ 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(),
|
||||||
@@ -747,24 +726,17 @@ function AddExtensionContent() {
|
|||||||
setPopoverOpen(open);
|
setPopoverOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WorkspaceQuotaTooltip
|
<PopoverTrigger asChild>
|
||||||
quota={extensionQuota}
|
<Button
|
||||||
resource={t('sidebar.extensions')}
|
variant="default"
|
||||||
>
|
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||||
<PopoverTrigger asChild>
|
>
|
||||||
<Button
|
<PlusIcon className="w-4 h-4" />
|
||||||
variant="default"
|
<span className="whitespace-nowrap">
|
||||||
disabled={extensionsReached}
|
{t('addExtension.manualAdd')}
|
||||||
aria-disabled={extensionsReached}
|
</span>
|
||||||
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"
|
</Button>
|
||||||
>
|
</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`}
|
||||||
@@ -773,19 +745,9 @@ 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
|
||||||
aria-disabled={extensionsReached}
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
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'
|
||||||
@@ -815,8 +777,7 @@ function AddExtensionContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
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">
|
||||||
@@ -835,8 +796,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
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">
|
||||||
@@ -855,8 +815,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={extensionsReached}
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
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);
|
||||||
@@ -923,7 +882,6 @@ 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();
|
||||||
@@ -988,7 +946,6 @@ function AddExtensionContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubAddressSubmit}
|
onClick={handleGithubAddressSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
extensionsReached ||
|
|
||||||
!githubURL.trim() ||
|
!githubURL.trim() ||
|
||||||
fetchingReleases ||
|
fetchingReleases ||
|
||||||
fetchingSkillPreview
|
fetchingSkillPreview
|
||||||
@@ -1145,11 +1102,7 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button className="w-full" onClick={handleGithubConfirm}>
|
||||||
className="w-full"
|
|
||||||
onClick={handleGithubConfirm}
|
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1231,7 +1184,6 @@ function AddExtensionContent() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubSkillConfirm}
|
onClick={handleGithubSkillConfirm}
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1288,8 +1240,6 @@ function AddExtensionContent() {
|
|||||||
<MarketPage
|
<MarketPage
|
||||||
installPlugin={handleInstallPlugin}
|
installPlugin={handleInstallPlugin}
|
||||||
headerActions={extensionActions}
|
headerActions={extensionActions}
|
||||||
installDisabled={extensionsReached}
|
|
||||||
installDisabledTooltip={extensionQuotaTooltip}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1375,17 +1325,9 @@ function AddExtensionContent() {
|
|||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<WorkspaceQuotaTooltip
|
<Button onClick={handleModalConfirm}>
|
||||||
quota={extensionQuota}
|
{t('common.confirm')}
|
||||||
resource={t('sidebar.extensions')}
|
</Button>
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={handleModalConfirm}
|
|
||||||
disabled={extensionsReached}
|
|
||||||
>
|
|
||||||
{t('common.confirm')}
|
|
||||||
</Button>
|
|
||||||
</WorkspaceQuotaTooltip>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||||
@@ -1417,8 +1359,6 @@ 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);
|
||||||
@@ -1452,8 +1392,6 @@ 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,9 +396,10 @@ 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="space-y-6" disabled={isLoading}>
|
<fieldset className="contents" disabled={isLoading}>
|
||||||
{/* Card 1: Basic Information */}
|
{/* Card 1: Basic Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 {
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
HardDrive,
|
||||||
Server,
|
Server,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@@ -109,11 +109,6 @@ 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 {
|
||||||
@@ -284,14 +279,6 @@ 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;
|
||||||
|
|
||||||
@@ -399,7 +386,6 @@ 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();
|
||||||
@@ -543,7 +529,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 and the extension market) render as plain links.
|
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={config.id}>
|
<SidebarMenuItem key={config.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
@@ -589,18 +575,6 @@ 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') {
|
||||||
@@ -933,144 +907,128 @@ 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 &&
|
||||||
<WorkspaceQuotaTooltip
|
(isPlugin ? (
|
||||||
quota={quota}
|
<DropdownMenu>
|
||||||
resource={config.name}
|
<DropdownMenuTrigger asChild>
|
||||||
side="right"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isPlugin ? (
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
<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-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?manual=1');
|
navigate('/home/add-extension');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Store className="size-4" />
|
||||||
{t('plugins.uploadLocal')}
|
{t('plugins.goToMarketplace')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate('/home/add-extension?manual=1');
|
e.stopPropagation();
|
||||||
setPopoverOpen((prev) => ({
|
navigate('/home/add-extension?manual=1');
|
||||||
...prev,
|
setPopoverOpen((prev) => ({
|
||||||
[config.id]: false,
|
...prev,
|
||||||
}));
|
[config.id]: false,
|
||||||
}}
|
}));
|
||||||
>
|
}}
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Upload className="size-4" />
|
||||||
</DropdownMenuItem>
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem
|
||||||
) : isSkill ? (
|
onClick={(e) => {
|
||||||
<DropdownMenu>
|
e.stopPropagation();
|
||||||
<DropdownMenuTrigger asChild>
|
navigate('/home/add-extension?manual=1');
|
||||||
<button
|
setPopoverOpen((prev) => ({
|
||||||
type="button"
|
...prev,
|
||||||
disabled={quota.disabled}
|
[config.id]: false,
|
||||||
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" />
|
||||||
<Plus className="size-3.5" />
|
{t('plugins.installFromGithub')}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenu>
|
||||||
<DropdownMenuItem
|
) : isSkill ? (
|
||||||
onClick={(e) => {
|
<DropdownMenu>
|
||||||
e.stopPropagation();
|
<DropdownMenuTrigger asChild>
|
||||||
navigate('/home/skills?action=create');
|
<button
|
||||||
setPopoverOpen((prev) => ({
|
type="button"
|
||||||
...prev,
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
[config.id]: false,
|
>
|
||||||
}));
|
<Plus className="size-3.5" />
|
||||||
}}
|
</button>
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<FilePlus2 className="size-4" />
|
<DropdownMenuContent align="end">
|
||||||
{t('skills.createManually')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/skills?action=create');
|
||||||
e.stopPropagation();
|
setPopoverOpen((prev) => ({
|
||||||
navigate('/home/add-extension?manual=1');
|
...prev,
|
||||||
setPopoverOpen((prev) => ({
|
[config.id]: false,
|
||||||
...prev,
|
}));
|
||||||
[config.id]: false,
|
}}
|
||||||
}));
|
>
|
||||||
}}
|
<FilePlus2 className="size-4" />
|
||||||
>
|
{t('skills.createManually')}
|
||||||
<Upload className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.uploadZip')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/add-extension?manual=1');
|
||||||
e.stopPropagation();
|
setPopoverOpen((prev) => ({
|
||||||
navigate('/home/add-extension?manual=1');
|
...prev,
|
||||||
setPopoverOpen((prev) => ({
|
[config.id]: false,
|
||||||
...prev,
|
}));
|
||||||
[config.id]: false,
|
}}
|
||||||
}));
|
>
|
||||||
}}
|
<Upload className="size-4" />
|
||||||
>
|
{t('skills.uploadZip')}
|
||||||
<Github className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.importFromGithub')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
</DropdownMenuContent>
|
e.stopPropagation();
|
||||||
</DropdownMenu>
|
navigate('/home/add-extension?manual=1');
|
||||||
) : (
|
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" />
|
||||||
onClick={() => {
|
{t('skills.importFromGithub')}
|
||||||
navigate(`${routePrefix}?id=new`);
|
</DropdownMenuItem>
|
||||||
setPopoverOpen((prev) => ({
|
</DropdownMenuContent>
|
||||||
...prev,
|
</DropdownMenu>
|
||||||
[config.id]: false,
|
) : (
|
||||||
}));
|
<button
|
||||||
}}
|
type="button"
|
||||||
>
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
<Plus className="size-3.5" />
|
onClick={() => {
|
||||||
</button>
|
navigate(`${routePrefix}?id=new`);
|
||||||
)}
|
setPopoverOpen((prev) => ({
|
||||||
</WorkspaceQuotaTooltip>
|
...prev,
|
||||||
)}
|
[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)}
|
||||||
@@ -1138,119 +1096,103 @@ function NavItems({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canCreate && (
|
{canCreate &&
|
||||||
<WorkspaceQuotaTooltip
|
(isPlugin ? (
|
||||||
quota={quota}
|
<DropdownMenu>
|
||||||
resource={config.name}
|
<DropdownMenuTrigger asChild>
|
||||||
side="right"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isPlugin ? (
|
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"
|
||||||
<DropdownMenu>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<DropdownMenuTrigger asChild>
|
>
|
||||||
<button
|
<Plus className="size-3.5" />
|
||||||
type="button"
|
</button>
|
||||||
disabled={quota.disabled}
|
</DropdownMenuTrigger>
|
||||||
aria-disabled={quota.disabled}
|
<DropdownMenuContent align="end">
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
{systemInfo.enable_marketplace && (
|
||||||
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?manual=1');
|
navigate('/home/add-extension');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Store className="size-4" />
|
||||||
{t('plugins.uploadLocal')}
|
{t('plugins.goToMarketplace')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
)}
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate('/home/add-extension?manual=1');
|
e.stopPropagation();
|
||||||
}}
|
navigate('/home/add-extension?manual=1');
|
||||||
>
|
}}
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Upload className="size-4" />
|
||||||
</DropdownMenuItem>
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem
|
||||||
) : isSkill ? (
|
onClick={(e) => {
|
||||||
<DropdownMenu>
|
e.stopPropagation();
|
||||||
<DropdownMenuTrigger asChild>
|
navigate('/home/add-extension?manual=1');
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
disabled={quota.disabled}
|
<Github className="size-4" />
|
||||||
aria-disabled={quota.disabled}
|
{t('plugins.installFromGithub')}
|
||||||
aria-label={`${t('common.create')} ${config.name}`}
|
</DropdownMenuItem>
|
||||||
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"
|
</DropdownMenuContent>
|
||||||
onClick={(e) => e.stopPropagation()}
|
</DropdownMenu>
|
||||||
>
|
) : isSkill ? (
|
||||||
<Plus className="size-3.5" />
|
<DropdownMenu>
|
||||||
</button>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuTrigger>
|
<button
|
||||||
<DropdownMenuContent align="end">
|
type="button"
|
||||||
<DropdownMenuItem
|
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) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
>
|
||||||
navigate('/home/skills?action=create');
|
<Plus className="size-3.5" />
|
||||||
}}
|
</button>
|
||||||
>
|
</DropdownMenuTrigger>
|
||||||
<FilePlus2 className="size-4" />
|
<DropdownMenuContent align="end">
|
||||||
{t('skills.createManually')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/skills?action=create');
|
||||||
e.stopPropagation();
|
}}
|
||||||
navigate('/home/add-extension?manual=1');
|
>
|
||||||
}}
|
<FilePlus2 className="size-4" />
|
||||||
>
|
{t('skills.createManually')}
|
||||||
<Upload className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.uploadZip')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
<DropdownMenuItem
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate('/home/add-extension?manual=1');
|
||||||
e.stopPropagation();
|
}}
|
||||||
navigate('/home/add-extension?manual=1');
|
>
|
||||||
}}
|
<Upload className="size-4" />
|
||||||
>
|
{t('skills.uploadZip')}
|
||||||
<Github className="size-4" />
|
</DropdownMenuItem>
|
||||||
{t('skills.importFromGithub')}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={(e) => {
|
||||||
</DropdownMenuContent>
|
e.stopPropagation();
|
||||||
</DropdownMenu>
|
navigate('/home/add-extension?manual=1');
|
||||||
) : (
|
}}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Github className="size-4" />
|
||||||
disabled={quota.disabled}
|
{t('skills.importFromGithub')}
|
||||||
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();
|
<button
|
||||||
navigate(`${routePrefix}?id=new`);
|
type="button"
|
||||||
}}
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||||
>
|
onClick={(e) => {
|
||||||
<Plus className="size-3.5" />
|
e.stopPropagation();
|
||||||
</button>
|
navigate(`${routePrefix}?id=new`);
|
||||||
)}
|
}}
|
||||||
</WorkspaceQuotaTooltip>
|
>
|
||||||
)}
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1695,13 +1637,6 @@ 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');
|
||||||
@@ -1980,11 +1915,9 @@ export default function HomeSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
{showWorkspaceSwitcher && (
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
</div>
|
||||||
</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">
|
||||||
@@ -2165,16 +2098,15 @@ export default function HomeSidebar({
|
|||||||
<UsersRound />
|
<UsersRound />
|
||||||
{t('workspace.settings')}
|
{t('workspace.settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canViewStorageAnalysis && (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={() => {
|
||||||
onClick={() => {
|
setUserMenuOpen(false);
|
||||||
setUserMenuOpen(false);
|
openSettings('storageAnalysis');
|
||||||
openSettings('storageAnalysis');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<HardDrive />
|
||||||
{t('storageAnalysis.title')}
|
{t('storageAnalysis.title')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -49,11 +48,9 @@ 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>;
|
||||||
@@ -80,36 +77,9 @@ 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>(() => {
|
||||||
@@ -126,11 +96,8 @@ 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 || '',
|
||||||
@@ -142,18 +109,13 @@ 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 || '',
|
||||||
@@ -164,18 +126,13 @@ 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 || '',
|
||||||
@@ -186,14 +143,11 @@ 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(),
|
||||||
@@ -201,9 +155,6 @@ 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>();
|
||||||
@@ -290,18 +241,13 @@ 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
|
||||||
@@ -311,18 +257,13 @@ 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,
|
||||||
@@ -332,22 +273,11 @@ 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(),
|
||||||
@@ -377,11 +307,9 @@ export function SidebarDataProvider({
|
|||||||
pipelines,
|
pipelines,
|
||||||
knowledgeBases,
|
knowledgeBases,
|
||||||
plugins,
|
plugins,
|
||||||
pluginCount,
|
|
||||||
mcpServers,
|
mcpServers,
|
||||||
skills,
|
skills,
|
||||||
pluginPages,
|
pluginPages,
|
||||||
quotaDataLoaded,
|
|
||||||
refreshBots,
|
refreshBots,
|
||||||
refreshPipelines,
|
refreshPipelines,
|
||||||
refreshKnowledgeBases,
|
refreshKnowledgeBases,
|
||||||
|
|||||||
@@ -218,22 +218,20 @@ export default function ProviderCard({
|
|||||||
<span>
|
<span>
|
||||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
</span>
|
</span>
|
||||||
{isWorkspaceOwner && (
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-5 w-5"
|
||||||
className="h-5 w-5"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
window.open(
|
||||||
window.open(
|
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
'_blank',
|
||||||
'_blank',
|
);
|
||||||
);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Plus className="h-3 w-3" />
|
||||||
<Plus className="h-3 w-3" />
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||||
|
|||||||
@@ -133,14 +133,12 @@ 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 canViewStorageAnalysis;
|
return canViewAudit;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -148,17 +146,11 @@ export default function SettingsDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forbiddenSection =
|
const forbiddenSection =
|
||||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
|
(section === 'storageAnalysis' && !canViewAudit);
|
||||||
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');
|
||||||
@@ -264,7 +256,7 @@ export default function SettingsDialog({
|
|||||||
active={open && section === 'apiIntegration'}
|
active={open && section === 'apiIntegration'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'storageAnalysis' && canViewStorageAnalysis && (
|
{section === 'storageAnalysis' && (
|
||||||
<StorageAnalysisPanel
|
<StorageAnalysisPanel
|
||||||
active={open && section === 'storageAnalysis'}
|
active={open && section === 'storageAnalysis'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
|
||||||
|
|
||||||
export function WorkspaceQuotaTooltip({
|
|
||||||
quota,
|
|
||||||
resource,
|
|
||||||
children,
|
|
||||||
side = 'top',
|
|
||||||
}: {
|
|
||||||
quota: WorkspaceQuotaItem;
|
|
||||||
resource: string;
|
|
||||||
children: ReactNode;
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!quota.disabled) return children;
|
|
||||||
const message = quota.loading
|
|
||||||
? t('limitation.quotaLoadingTooltip')
|
|
||||||
: t('limitation.createDisabledTooltip', {
|
|
||||||
resource,
|
|
||||||
max: quota.max,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
tabIndex={0}
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-label={message}
|
|
||||||
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={side} className="max-w-72 text-left">
|
|
||||||
{message}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
|
||||||
|
|
||||||
export interface WorkspaceQuotaItem {
|
|
||||||
count: number;
|
|
||||||
max: number;
|
|
||||||
reached: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceQuotaStatus {
|
|
||||||
bots: WorkspaceQuotaItem;
|
|
||||||
pipelines: WorkspaceQuotaItem;
|
|
||||||
knowledgeBases: WorkspaceQuotaItem;
|
|
||||||
extensions: WorkspaceQuotaItem;
|
|
||||||
botsReached: boolean;
|
|
||||||
pipelinesReached: boolean;
|
|
||||||
knowledgeBasesReached: boolean;
|
|
||||||
extensionsReached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotaItem(
|
|
||||||
count: number,
|
|
||||||
max: number | undefined,
|
|
||||||
loaded: boolean,
|
|
||||||
): WorkspaceQuotaItem {
|
|
||||||
const normalizedMax = typeof max === 'number' ? max : -1;
|
|
||||||
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
max: normalizedMax,
|
|
||||||
reached,
|
|
||||||
loading: !loaded,
|
|
||||||
disabled: !loaded || reached,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
|
||||||
const {
|
|
||||||
bots,
|
|
||||||
pipelines,
|
|
||||||
knowledgeBases,
|
|
||||||
pluginCount,
|
|
||||||
mcpServers,
|
|
||||||
skills,
|
|
||||||
quotaDataLoaded,
|
|
||||||
} = useSidebarData();
|
|
||||||
const limitation = systemInfo.limitation;
|
|
||||||
|
|
||||||
const botQuota = quotaItem(
|
|
||||||
bots.length,
|
|
||||||
limitation?.max_bots,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const pipelineQuota = quotaItem(
|
|
||||||
pipelines.length,
|
|
||||||
limitation?.max_pipelines,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const knowledgeBaseQuota = quotaItem(
|
|
||||||
knowledgeBases.length,
|
|
||||||
limitation?.max_knowledge_bases,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
const extensionQuota = quotaItem(
|
|
||||||
pluginCount + mcpServers.length + skills.length,
|
|
||||||
limitation?.max_extensions,
|
|
||||||
quotaDataLoaded,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
bots: botQuota,
|
|
||||||
pipelines: pipelineQuota,
|
|
||||||
knowledgeBases: knowledgeBaseQuota,
|
|
||||||
extensions: extensionQuota,
|
|
||||||
botsReached: botQuota.disabled,
|
|
||||||
pipelinesReached: pipelineQuota.disabled,
|
|
||||||
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
|
||||||
extensionsReached: extensionQuota.disabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -79,6 +79,7 @@ export default function WorkspaceSettingsPanel({
|
|||||||
const canInvite = permissions.has('member.invite');
|
const 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`
|
||||||
: '';
|
: '';
|
||||||
@@ -313,20 +314,18 @@ export default function WorkspaceSettingsPanel({
|
|||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent className="min-w-0">
|
<ItemContent>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
{member.display_name}
|
{member.email}
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<Badge variant="outline">{t('workspace.you')}</Badge>
|
<Badge variant="outline">{t('workspace.you')}</Badge>
|
||||||
)}
|
)}
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
<ItemDescription>
|
||||||
<span className="break-all">{member.email}</span>
|
{t(`workspace.roles.${member.role}`)}
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{t(`workspace.roles.${member.role}`)}</span>
|
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
|
<ItemActions>
|
||||||
{canUpdateMembers && member.role !== 'owner' && (
|
{canUpdateMembers && member.role !== 'owner' && (
|
||||||
<Select
|
<Select
|
||||||
value={member.role}
|
value={member.role}
|
||||||
@@ -343,6 +342,11 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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.
|
||||||
@@ -81,15 +80,11 @@ function PluginPageIframe({
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}) {
|
}) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
|
const [loading, setLoading] = useState(true);
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { t, i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
|
|
||||||
author,
|
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||||
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
|
||||||
@@ -175,29 +170,23 @@ function PluginPageIframe({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
{assetError ? (
|
{loading && (
|
||||||
<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,8 +7,6 @@ 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>
|
||||||
@@ -18,8 +16,6 @@ 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 {
|
||||||
@@ -34,8 +30,6 @@ 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();
|
||||||
@@ -69,7 +63,6 @@ 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 {
|
||||||
@@ -197,27 +190,13 @@ export default function PluginLocalPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{quota ? (
|
<Button
|
||||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
type="button"
|
||||||
<Button
|
onClick={handleInstall}
|
||||||
type="button"
|
disabled={!preview || previewing || installing}
|
||||||
onClick={handleInstall}
|
>
|
||||||
disabled={quota.disabled || !preview || previewing || installing}
|
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||||
>
|
</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,13 +80,9 @@ 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();
|
||||||
@@ -851,8 +847,6 @@ function MarketPageContent({
|
|||||||
lists={recommendationLists}
|
lists={recommendationLists}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
installDisabled={installDisabled}
|
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -882,8 +876,6 @@ function MarketPageContent({
|
|||||||
cardVO={plugin}
|
cardVO={plugin}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
installDisabled={installDisabled}
|
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -923,13 +915,9 @@ 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
|
||||||
@@ -944,8 +932,6 @@ export default function MarketPage({
|
|||||||
<MarketPageContent
|
<MarketPageContent
|
||||||
installPlugin={installPlugin}
|
installPlugin={installPlugin}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
installDisabled={installDisabled}
|
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,15 +54,11 @@ 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();
|
||||||
@@ -267,8 +263,6 @@ function RecommendationListRow({
|
|||||||
cardVO={pluginToVO(plugin, t)}
|
cardVO={pluginToVO(plugin, t)}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
installDisabled={installDisabled}
|
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,14 +277,10 @@ 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;
|
||||||
|
|
||||||
@@ -302,8 +292,6 @@ 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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+3
-26
@@ -23,14 +23,10 @@ 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);
|
||||||
@@ -131,7 +127,6 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,17 +153,12 @@ export default function PluginMarketCardComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardContent = (
|
return (
|
||||||
<div
|
<div
|
||||||
role={installDisabled ? 'group' : 'button'}
|
role="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] 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 ${
|
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"
|
||||||
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 (
|
||||||
@@ -392,17 +382,4 @@ 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ 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);
|
||||||
@@ -276,13 +275,6 @@ 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,8 +7,6 @@ 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;
|
||||||
@@ -18,8 +16,6 @@ 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 {
|
||||||
@@ -49,8 +45,6 @@ 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[]>([]);
|
||||||
@@ -123,7 +117,6 @@ 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);
|
||||||
@@ -256,56 +249,28 @@ export default function SkillZipPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{quota ? (
|
<Button
|
||||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
type="button"
|
||||||
<Button
|
onClick={handleInstall}
|
||||||
type="button"
|
disabled={
|
||||||
onClick={handleInstall}
|
previewing ||
|
||||||
disabled={
|
installing ||
|
||||||
quota.disabled ||
|
previewSkills.length === 0 ||
|
||||||
previewing ||
|
selectedPaths.length === 0
|
||||||
installing ||
|
}
|
||||||
previewSkills.length === 0 ||
|
>
|
||||||
selectedPaths.length === 0
|
{installing ? (
|
||||||
}
|
<>
|
||||||
>
|
<Loader2 className="size-4 animate-spin" />
|
||||||
{installing ? (
|
{t('skills.installing')}
|
||||||
<>
|
</>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
) : (
|
||||||
{t('skills.installing')}
|
<>
|
||||||
</>
|
<PackageOpen className="size-4" />
|
||||||
) : (
|
{t('skills.confirmInstall')}
|
||||||
<>
|
</>
|
||||||
<PackageOpen className="size-4" />
|
)}
|
||||||
{t('skills.confirmInstall')}
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ 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. */
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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';
|
||||||
|
|||||||
@@ -1091,7 +1091,6 @@ 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');
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1179,6 @@ 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;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user