diff --git a/.github/drone.yml b/.github/drone.yml deleted file mode 100644 index 5565b2281..000000000 --- a/.github/drone.yml +++ /dev/null @@ -1,302 +0,0 @@ -# ============================================================ -# Clawith CI/CD Pipeline -# 基于 Drone CI 的自动化构建、本地部署测试和升级测试 -# -# 流程: -# 1. 代码克隆 (获取完整历史和 tags) -# 2. 构建 Docker 镜像 -# 3. 本地部署测试 (直接利用本地 docker socket 和 docker-compose.ci.yml) -# 4. 本地升级测试 (测试旧版到新版的迁移) -# 5. 打包传输到服务器 (可选) -# ============================================================ -kind: pipeline -type: docker -name: build-and-test - -workspace: - path: /drone/src - -clone: - disable: true - -steps: - # -------------------------------------------------------- - # Step 1: 代码克隆 (获取完整历史和 tags) - # -------------------------------------------------------- - - name: clone - image: alpine/git - pull: if-not-exists - environment: - http_proxy: - from_secret: PROXY - https_proxy: - from_secret: PROXY - commands: - - git config --global core.compression 0 - - git config --global http.postBuffer 524288000 - - git clone --no-single-branch https://github.com/dataelement/Clawith.git . - # 将 shallow clone 转换为完整克隆,获取完整 commit 历史 - - git fetch --unshallow --tags || git fetch --tags - - git checkout $DRONE_COMMIT - - echo "当前 commit $(git log --oneline -1)" - - echo "上一个 tag $(git describe --tags --abbrev=0 2>/dev/null || echo '无 tag')" - - # -------------------------------------------------------- - # Step 2: 构建新旧版本 Docker 镜像 - # -------------------------------------------------------- - - name: build-images - image: docker:24.0.6 - pull: if-not-exists - privileged: true - volumes: - - name: docker-socket - path: /var/run/docker.sock - environment: - http_proxy: - from_secret: PROXY - https_proxy: - from_secret: PROXY - commands: - - echo "========================================" - - echo "开始构建 Docker 镜像" - - echo "事件 $DRONE_BUILD_EVENT" - - echo "目标分支 $DRONE_BRANCH" - - echo "提交 $DRONE_COMMIT_SHORT" - - echo "========================================" - - # 获取上一个 tag - - PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - - | - if [ -z "$PREV_TAG" ]; then - echo "警告: 未找到历史 tag,升级测试将被跳过" - echo "SKIP_UPGRADE_TEST=true" > .env.drone - # 仅构建当前版本 - docker build -t clawith-backend:new --build-arg CLAWITH_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple -f backend/Dockerfile ./backend - docker build -t clawith-frontend:new -f frontend/Dockerfile ./frontend - echo "当前版本镜像构建完成" - exit 0 - fi - - echo "上一个 tag $PREV_TAG" - - | - echo "SKIP_UPGRADE_TEST=false" > .env.drone - - # --- 构建旧版本镜像 --- - - echo "--- 构建旧版本镜像 ($PREV_TAG) ---" - - git checkout $PREV_TAG - - docker build -t clawith-backend:old --build-arg CLAWITH_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple -f backend/Dockerfile ./backend - - docker build -t clawith-frontend:old -f frontend/Dockerfile ./frontend - - echo "旧版本镜像构建完成" - - # --- 构建当前版本镜像 --- - - echo "--- 构建当前版本镜像 ($DRONE_COMMIT_SHORT) ---" - - git checkout $DRONE_COMMIT - - docker build -t clawith-backend:new --build-arg CLAWITH_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple -f backend/Dockerfile ./backend - - docker build -t clawith-frontend:new -f frontend/Dockerfile ./frontend - - echo "当前版本镜像构建完成" - - # -------------------------------------------------------- - # Step 3: 本地部署测试 (Deploy Test) - # -------------------------------------------------------- - - name: local-deploy-test - image: docker:24.0.6 - privileged: true - volumes: - - name: docker-socket - path: /var/run/docker.sock - commands: - - apk add --no-cache docker-cli-compose curl bash - - echo "========================================" - - echo "本地部署测试 全新部署验证" - - echo "========================================" - - docker compose -f docker-compose.ci.yml down -v --remove-orphans 2>/dev/null || true - - echo "启动全部服务 (postgres/redis/backend/frontend)..." - - IMAGE_TAG=new docker compose -f docker-compose.ci.yml up -d --no-build - - sleep 5 - - # 等待基础设施就绪 - - echo "等待 postgres 和 redis 健康检查通过..." - - timeout 30s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" $(docker compose -f docker-compose.ci.yml ps -q postgres) 2>/dev/null)" = "healthy" ]; do sleep 2; done' || (echo "postgres 启动超时" && docker compose -f docker-compose.ci.yml logs postgres && exit 1) - - timeout 30s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" $(docker compose -f docker-compose.ci.yml ps -q redis) 2>/dev/null)" = "healthy" ]; do sleep 2; done' || (echo "redis 启动超时" && docker compose -f docker-compose.ci.yml logs redis && exit 1) - - # 核心验证: backend 启动 - - echo "等待 backend 启动 (最多 90 秒)..." - - timeout 90s bash -c 'until docker compose -f docker-compose.ci.yml logs --tail=200 backend 2>&1 | grep -q "Application startup complete"; do sleep 3; done' - - RESULT=$? - - | - if [ $RESULT -ne 0 ]; then - echo "❌ 部署测试失败: backend 启动超时" - docker compose -f docker-compose.ci.yml logs --tail=100 backend - exit 1 - fi - - # 检查迁移是否失败 - - echo "检查数据库迁移状态..." - - | - MIGRATION_LOG=$(docker compose -f docker-compose.ci.yml logs --tail=200 backend 2>&1) - if echo "$MIGRATION_LOG" | grep -Eqi "migration.*FAIL|alembic.*error"; then - echo "❌ 部署测试失败: 检测到数据库迁移失败" - echo "$MIGRATION_LOG" | grep -Ei "migration|alembic|error|FAIL" - exit 1 - fi - - # 验证 health endpoint (通过 docker compose exec 直接在容器内 curl) - - echo "验证 health endpoint..." - - timeout 10s bash -c 'until docker compose -f docker-compose.ci.yml exec -T backend curl -sf http://localhost:8000/api/health > /dev/null 2>&1; do sleep 2; done' || (echo "❌ health endpoint 不可达" && exit 1) - - # 清理环境 - - echo "清理测试环境..." - - docker compose -f docker-compose.ci.yml down -v --remove-orphans - - echo "✅ 本地部署测试通过" - - # -------------------------------------------------------- - # Step 4: 本地升级测试 (Upgrade Test) - # -------------------------------------------------------- - - name: local-upgrade-test - image: docker:24.0.6 - privileged: true - volumes: - - name: docker-socket - path: /var/run/docker.sock - commands: - - apk add --no-cache docker-cli-compose curl bash - - echo "========================================" - - echo "本地升级测试 旧版 → 新版数据库迁移验证" - - echo "========================================" - - | - if [ -f ".env.drone" ] && grep -q "SKIP_UPGRADE_TEST=true" ".env.drone"; then - echo "⚠️ 未找到历史 tag,跳过升级测试" - exit 0 - fi - - - docker compose -f docker-compose.ci.yml down -v --remove-orphans 2>/dev/null || true - - docker rm -f backend-upgrade-test 2>/dev/null || true - - # 阶段 1: 部署旧版本 - - echo "--- 阶段 1: 部署旧版本 ---" - - IMAGE_TAG=old docker compose -f docker-compose.ci.yml up -d postgres redis - - sleep 5 - - timeout 30s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" $(docker compose -f docker-compose.ci.yml ps -q postgres) 2>/dev/null)" = "healthy" ]; do sleep 2; done' - - timeout 30s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" $(docker compose -f docker-compose.ci.yml ps -q redis) 2>/dev/null)" = "healthy" ]; do sleep 2; done' - - # 启动旧版 backend - - | - docker run -d --name backend-upgrade-test \ - --network clawith_network \ - -e DATABASE_URL=postgresql+asyncpg://clawith:clawith@postgres:5432/clawith \ - -e REDIS_URL=redis://redis:6379/0 \ - -e AGENT_DATA_DIR=/data/agents \ - -e AGENT_TEMPLATE_DIR=/app/agent_template \ - -e SECRET_KEY=ci-test-secret \ - -e JWT_SECRET_KEY=ci-test-jwt-secret \ - -e CORS_ORIGINS='["*"]' \ - clawith-backend:old - - - timeout 90s bash -c 'until docker logs backend-upgrade-test 2>&1 | grep -q "Application startup complete"; do sleep 3; done' - - RESULT=$? - - | - if [ $RESULT -ne 0 ]; then - echo "❌ 升级测试失败: 旧版 backend 启动超时" - docker logs --tail=100 backend-upgrade-test - exit 1 - fi - - - docker exec backend-upgrade-test alembic history 2>&1 | head -5 || true - - docker stop backend-upgrade-test - - docker rm backend-upgrade-test - - # 阶段 2: 升级到新版本 - - echo "--- 阶段 2: 升级到新版本 ---" - - | - docker run -d --name backend-upgrade-test \ - --network clawith_network \ - -e DATABASE_URL=postgresql+asyncpg://clawith:clawith@postgres:5432/clawith \ - -e REDIS_URL=redis://redis:6379/0 \ - -e AGENT_DATA_DIR=/data/agents \ - -e AGENT_TEMPLATE_DIR=/app/agent_template \ - -e SECRET_KEY=ci-test-secret \ - -e JWT_SECRET_KEY=ci-test-jwt-secret \ - -e CORS_ORIGINS='["*"]' \ - clawith-backend:new - - - timeout 120s bash -c 'until docker logs backend-upgrade-test 2>&1 | grep -q "Application startup complete"; do sleep 3; done' - - RESULT=$? - - | - if [ $RESULT -ne 0 ]; then - echo "❌ 升级测试失败: 新版 backend 启动超时" - docker logs --tail=200 backend-upgrade-test - exit 1 - fi - - - | - UPGRADE_LOG=$(docker logs --tail=300 backend-upgrade-test 2>&1) - if echo "$UPGRADE_LOG" | grep -Eqi "migration.*FAIL|alembic.*error|WARNING.*migration"; then - echo "❌ 升级测试失败: 检测到数据库迁移异常" - echo "$UPGRADE_LOG" | grep -Ei "migration|alembic|error|FAIL|WARNING" - exit 1 - fi - - - timeout 10s bash -c 'until docker exec backend-upgrade-test curl -sf http://localhost:8000/api/health > /dev/null 2>&1; do sleep 2; done' || (echo "❌ health endpoint 不可达" && exit 1) - - - docker exec backend-upgrade-test alembic history 2>&1 | head -5 || true - - docker exec backend-upgrade-test alembic current 2>&1 || true - - - docker rm -f backend-upgrade-test - - docker compose -f docker-compose.ci.yml down -v --remove-orphans - - echo "✅ 本地升级测试通过" - - # -------------------------------------------------------- - # Step 5: (可选) 导出镜像并传输到私有服务器 - # 只有在部署测试通过后才会执行到这一步 - # -------------------------------------------------------- - - name: save-images - image: docker:24.0.6 - privileged: true - volumes: - - name: docker-socket - path: /var/run/docker.sock - commands: - - echo "测试全部通过,开始导出镜像文件..." - - docker save -o clawith-backend-new.tar clawith-backend:new - - docker save -o clawith-frontend-new.tar clawith-frontend:new - - | - if [ -f ".env.drone" ] && grep -q "SKIP_UPGRADE_TEST=false" ".env.drone"; then - docker save -o clawith-backend-old.tar clawith-backend:old - docker save -o clawith-frontend-old.tar clawith-frontend:old - fi - - chmod 644 *.tar - - ls -lh *.tar - - - name: scp-images - image: appleboy/drone-scp - pull: if-not-exists - settings: - host: - from_secret: PRIVATE_SERVER_IP - username: root - password: - from_secret: sshpwd - port: 22 - command_timeout: 15m - target: /opt/server/clawith-ci/ - source: - - clawith-backend-new.tar - - clawith-frontend-new.tar - - clawith-backend-old.tar - - clawith-frontend-old.tar - - docker-compose.ci.yml - - .env.drone - rm: false - -trigger: - branch: - - main - - release - - feat-devops - event: - - push - - pull_request - -volumes: - - name: docker-socket - host: - path: /var/run/docker.sock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..6cf93796c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + backend: + name: Backend + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: clawith + POSTGRES_USER: clawith + POSTGRES_PASSWORD: clawith + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U clawith -d clawith" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql+asyncpg://clawith:clawith@127.0.0.1:5432/clawith + REDIS_URL: redis://127.0.0.1:6379/0 + AGENT_DATA_DIR: ${{ github.workspace }}/backend/agent_data + AGENT_TEMPLATE_DIR: ${{ github.workspace }}/backend/agent_templates + SECRET_KEY: ci-secret-key + JWT_SECRET_KEY: ci-jwt-secret + PUBLIC_BASE_URL: http://localhost:8000 + defaults: + run: + working-directory: backend + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + + - name: Run Ruff + run: python -m ruff check app tests + + - name: Run Alembic upgrade + run: alembic upgrade head + + - name: Check Alembic current revision + run: alembic current + + - name: Run pytest + run: python -m pytest tests -q + + - name: Validate deploy script syntax + working-directory: ${{ github.workspace }} + run: bash -n scripts/deploy/deploy_from_github_actions.sh + + frontend: + name: Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Run frontend tests + run: npm test + + - name: Build frontend + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..20163dc29 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,97 @@ +name: Deploy Production + +on: + workflow_run: + workflows: ["CI"] + branches: [main] + types: [completed] + +permissions: + contents: read + +concurrency: + group: production-deploy + cancel-in-progress: false + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: production + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Confirm workflow_run SHA is still current on origin/main + id: freshness + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + : "${DEPLOY_SHA:?DEPLOY_SHA is required}" + + git fetch --no-tags origin main + MAIN_HEAD="$(git rev-parse FETCH_HEAD)" + + if [[ "$DEPLOY_SHA" != "$MAIN_HEAD" ]]; then + echo "Skipping stale deploy candidate: workflow_run SHA $DEPLOY_SHA is behind current origin/main $MAIN_HEAD" >&2 + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "proceed=true" >> "$GITHUB_OUTPUT" + + - name: Configure SSH + if: ${{ steps.freshness.outputs.proceed == 'true' }} + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_PORT: ${{ secrets.SSH_PORT }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} + run: | + set -euo pipefail + : "${SSH_HOST:?SSH_HOST is required}" + : "${SSH_USER:?SSH_USER is required}" + : "${SSH_PRIVATE_KEY:?SSH_PRIVATE_KEY is required}" + : "${SSH_KNOWN_HOSTS:?SSH_KNOWN_HOSTS is required}" + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + SSH_PORT="${SSH_PORT:-22}" + + printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Deploy to production + if: ${{ steps.freshness.outputs.proceed == 'true' }} + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + SSH_PORT: ${{ secrets.SSH_PORT }} + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + : "${SSH_HOST:?SSH_HOST is required}" + : "${SSH_USER:?SSH_USER is required}" + : "${DEPLOY_SHA:?DEPLOY_SHA is required}" + + SSH_PORT="${SSH_PORT:-22}" + + ssh \ + -p "$SSH_PORT" \ + -o BatchMode=yes \ + -o ConnectTimeout=10 \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=3 \ + -o StrictHostKeyChecking=yes \ + "$SSH_USER@$SSH_HOST" \ + "bash -s -- '$DEPLOY_SHA' '/opt/clawith'" < scripts/deploy/deploy_from_github_actions.sh diff --git a/.gitignore b/.gitignore index fe83a12a5..2066bb6bb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,14 @@ _agents/ # Internal docs docs/ .agents/rules/deploy.md + +# Runtime data (postgres, redis, agent workspaces) +data/ + +# Infrastructure config (not code) +cloudfront-config.json + +# Unnecessary extra requirements file (deps in pyproject.toml) +backend/requirements.txt +.worktrees/ +.gitnexus diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deac03729..281ae050a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,30 @@ Please describe: - Frontend: TypeScript — standard React conventions 5. Use `Fixes #` in the PR description +## GitHub Actions CI/CD + +GitHub Actions is now the active in-repo CI/CD path after the Drone pipeline was removed. + +- Pull requests to `main` run CI automatically. +- On pushes to `main`, production deployment runs after CI succeeds. +- Production deployment uses the GitHub Actions `production` environment. + +Current verification boundary for this first migration: + +- Covered now: backend/frontend CI plus the production deploy workflow. +- Not yet covered as a full replacement: the old Drone deploy-test and upgrade-test rehearsal coverage. + +For maintainers setting up the production environment bootstrap in GitHub Actions, configure these environment secrets with GitHub CLI: + +```bash +gh secret set SSH_HOST --env production --body "ai-company.growatt-support.com" +gh secret set SSH_USER --env production --body "ubuntu" +gh secret set SSH_PRIVATE_KEY --env production < ~/.ssh/your_key +gh secret set SSH_KNOWN_HOSTS --env production < ~/.ssh/known_hosts +# Optional +gh secret set SSH_PORT --env production --body "22" +``` + ## Working on Multiple Features It is common to develop several improvements in one sitting before submitting. Rather than sending one giant PR, please split your work into smaller, focused PRs — this makes review faster and merges cleaner. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index eb3420d3f..f31ea6068 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -31,7 +31,23 @@ from app.models.tool import Tool # noqa: F401 from app.models.trigger import AgentTrigger # noqa: F401 from app.models.agent_credential import AgentCredential # noqa: F401 +from app.models.focus import AgentFocusItem # noqa: F401 +from app.models.gateway_message import GatewayMessage # noqa: F401 +from app.models.notification import Notification # noqa: F401 +from app.models.okr import ( # noqa: F401 + CompanyReport, + MemberDailyReport, + OKRAlignment, + OKRKeyResult, + OKRObjective, + OKRProgressLog, + OKRSettings, + WorkReport, +) from app.models.onboarding import UserTenantOnboarding # noqa: F401 +from app.models.published_page import PublishedPage # noqa: F401 +from app.models.tenant_setting import TenantSetting # noqa: F401 +from app.models.workspace import WorkspaceBugReport, WorkspaceEditLock, WorkspaceFileRevision, WorkspaceProject # noqa: F401 config = context.config settings = get_settings() diff --git a/backend/alembic/versions/20260313_column_modify.py b/backend/alembic/versions/20260313_column_modify.py index 5781535d4..5ff1a4a3b 100644 --- a/backend/alembic/versions/20260313_column_modify.py +++ b/backend/alembic/versions/20260313_column_modify.py @@ -4,8 +4,6 @@ Revises: add_microsoft_teams_support """ from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID revision = "20260313_column_modify" down_revision = "add_microsoft_teams_support" diff --git a/backend/alembic/versions/20260330_refactor_user_system_phase2.py b/backend/alembic/versions/20260330_refactor_user_system_phase2.py index 17647feef..7967db23b 100644 --- a/backend/alembic/versions/20260330_refactor_user_system_phase2.py +++ b/backend/alembic/versions/20260330_refactor_user_system_phase2.py @@ -9,7 +9,6 @@ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql from sqlalchemy import inspect # revision identifiers, used by Alembic. diff --git a/backend/alembic/versions/5b0be8fbd941_add_chat_message_is_hidden_column.py b/backend/alembic/versions/5b0be8fbd941_add_chat_message_is_hidden_column.py new file mode 100644 index 000000000..c1e3fab47 --- /dev/null +++ b/backend/alembic/versions/5b0be8fbd941_add_chat_message_is_hidden_column.py @@ -0,0 +1,26 @@ +"""add chat_message is_hidden column + +Revision ID: 5b0be8fbd941 +Revises: add_user_tenant_onboarding +Create Date: 2026-03-25 21:47:36.761546 +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '5b0be8fbd941' +down_revision: Union[str, None] = 'add_user_tenant_onboarding' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE chat_messages " + "ADD COLUMN IF NOT EXISTS is_hidden BOOLEAN NOT NULL DEFAULT FALSE" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE chat_messages DROP COLUMN IF EXISTS is_hidden") diff --git a/backend/alembic/versions/add_agent_is_system.py b/backend/alembic/versions/add_agent_is_system.py index 3a7a05b82..628d906c2 100644 --- a/backend/alembic/versions/add_agent_is_system.py +++ b/backend/alembic/versions/add_agent_is_system.py @@ -5,7 +5,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers revision = "add_agent_is_system" diff --git a/backend/alembic/versions/add_agent_tool_source.py b/backend/alembic/versions/add_agent_tool_source.py index 030ea519e..79f5aca5d 100644 --- a/backend/alembic/versions/add_agent_tool_source.py +++ b/backend/alembic/versions/add_agent_tool_source.py @@ -5,7 +5,6 @@ Create Date: 2026-03-06 """ from alembic import op -import sqlalchemy as sa revision = "add_agent_tool_source" down_revision = "add_quota_fields" diff --git a/backend/alembic/versions/add_chat_sessions.py b/backend/alembic/versions/add_chat_sessions.py index 815c1b65e..c18147486 100644 --- a/backend/alembic/versions/add_chat_sessions.py +++ b/backend/alembic/versions/add_chat_sessions.py @@ -1,7 +1,5 @@ """Add chat_sessions table and update existing chat_messages conversation_ids.""" -import uuid -import sqlalchemy as sa from alembic import op revision = "add_chat_sessions" diff --git a/backend/alembic/versions/add_daily_token_usage.py b/backend/alembic/versions/add_daily_token_usage.py index 385e1b043..55d3f1dcb 100644 --- a/backend/alembic/versions/add_daily_token_usage.py +++ b/backend/alembic/versions/add_daily_token_usage.py @@ -4,8 +4,6 @@ """ from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql revision = "add_daily_token_usage" down_revision = "add_agentbay_enum_value" diff --git a/backend/alembic/versions/add_invitation_codes.py b/backend/alembic/versions/add_invitation_codes.py index 382e30e27..eddcb014e 100644 --- a/backend/alembic/versions/add_invitation_codes.py +++ b/backend/alembic/versions/add_invitation_codes.py @@ -4,7 +4,6 @@ """ from alembic import op -import sqlalchemy as sa revision = "add_invitation_codes" down_revision = "add_chat_sessions" diff --git a/backend/alembic/versions/add_llm_max_output_tokens.py b/backend/alembic/versions/add_llm_max_output_tokens.py index b6892ca4a..16aeee9ad 100644 --- a/backend/alembic/versions/add_llm_max_output_tokens.py +++ b/backend/alembic/versions/add_llm_max_output_tokens.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'add_llm_max_output_tokens' diff --git a/backend/alembic/versions/add_llm_temperature.py b/backend/alembic/versions/add_llm_temperature.py index e4f57de09..4b077308a 100644 --- a/backend/alembic/versions/add_llm_temperature.py +++ b/backend/alembic/versions/add_llm_temperature.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'add_llm_temperature' diff --git a/backend/alembic/versions/add_llm_tenant_id.py b/backend/alembic/versions/add_llm_tenant_id.py index 8d71d53a6..cf44e62e8 100644 --- a/backend/alembic/versions/add_llm_tenant_id.py +++ b/backend/alembic/versions/add_llm_tenant_id.py @@ -4,8 +4,6 @@ Revises: 20260313_column_modify """ from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID revision = "add_llm_tenant_id" down_revision = "20260313_column_modify" diff --git a/backend/alembic/versions/add_microsoft_teams_support.py b/backend/alembic/versions/add_microsoft_teams_support.py index d09aa6c2b..bfcfbf163 100644 --- a/backend/alembic/versions/add_microsoft_teams_support.py +++ b/backend/alembic/versions/add_microsoft_teams_support.py @@ -1,7 +1,6 @@ """Add Microsoft Teams support to im_provider and channel_type enums.""" from alembic import op -import sqlalchemy as sa revision = "add_microsoft_teams_support" down_revision = "add_agent_usage_fields" diff --git a/backend/alembic/versions/add_notification_agent_id.py b/backend/alembic/versions/add_notification_agent_id.py index 639d92de0..d30765b08 100644 --- a/backend/alembic/versions/add_notification_agent_id.py +++ b/backend/alembic/versions/add_notification_agent_id.py @@ -7,7 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa revision: str = 'add_notification_agent_id' diff --git a/backend/alembic/versions/add_notifications_table.py b/backend/alembic/versions/add_notifications_table.py new file mode 100644 index 000000000..8ad820571 --- /dev/null +++ b/backend/alembic/versions/add_notifications_table.py @@ -0,0 +1,57 @@ +"""Create notifications table for legacy installs. + +Revision ID: add_notifications_table +Revises: add_workspace_deployment_tables +Create Date: 2026-05-12 +""" + +from alembic import op + + +revision = "add_notifications_table" +down_revision = "add_workspace_deployment_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + agent_id UUID REFERENCES agents(id), + type VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + body TEXT NOT NULL DEFAULT '', + link VARCHAR(500), + ref_id UUID, + sender_name VARCHAR(100), + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT now() + ) + """ + ) + + for statement in [ + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS agent_id UUID REFERENCES agents(id)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS type VARCHAR(50)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS title VARCHAR(200)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS body TEXT NOT NULL DEFAULT ''", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS link VARCHAR(500)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS ref_id UUID", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS sender_name VARCHAR(100)", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS is_read BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE notifications ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now()", + "ALTER TABLE notifications ALTER COLUMN user_id DROP NOT NULL", + ]: + op.execute(statement) + + op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications(user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_agent_id ON notifications(agent_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_created_at ON notifications(created_at)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS notifications") diff --git a/backend/alembic/versions/add_published_pages.py b/backend/alembic/versions/add_published_pages.py index a73dc09e4..53f758858 100644 --- a/backend/alembic/versions/add_published_pages.py +++ b/backend/alembic/versions/add_published_pages.py @@ -7,8 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID revision: str = 'add_published_pages' diff --git a/backend/alembic/versions/add_skill_tenant_id.py b/backend/alembic/versions/add_skill_tenant_id.py index 6cc6eb2ec..27e492231 100644 --- a/backend/alembic/versions/add_skill_tenant_id.py +++ b/backend/alembic/versions/add_skill_tenant_id.py @@ -4,7 +4,6 @@ Revises: add_llm_tenant_id """ from alembic import op -import sqlalchemy as sa revision = "add_skill_tenant_id" down_revision = "add_llm_tenant_id" diff --git a/backend/alembic/versions/add_sso_login_enabled.py b/backend/alembic/versions/add_sso_login_enabled.py index 5d2a33acb..b4683f5c0 100644 --- a/backend/alembic/versions/add_sso_login_enabled.py +++ b/backend/alembic/versions/add_sso_login_enabled.py @@ -5,7 +5,6 @@ Create Date: 2026-03-29 """ from alembic import op -import sqlalchemy as sa revision = "add_sso_login_enabled" down_revision = "user_refactor_v1" diff --git a/backend/alembic/versions/add_title_edit_util_model.py b/backend/alembic/versions/add_title_edit_util_model.py new file mode 100644 index 000000000..e8198792a --- /dev/null +++ b/backend/alembic/versions/add_title_edit_util_model.py @@ -0,0 +1,27 @@ +"""Add title_edited to chat_sessions and utility_model_id to tenants. + +Revision ID: add_title_edit_util_model +Revises: 5b0be8fbd941 +""" +from alembic import op + +revision = "add_title_edit_util_model" +down_revision = "5b0be8fbd941" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS " + "title_edited BOOLEAN NOT NULL DEFAULT FALSE" + ) + op.execute( + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS " + "utility_model_id UUID REFERENCES llm_models(id) ON DELETE SET NULL" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS utility_model_id") + op.execute("ALTER TABLE chat_sessions DROP COLUMN IF EXISTS title_edited") diff --git a/backend/alembic/versions/add_tool_source.py b/backend/alembic/versions/add_tool_source.py index 06815b314..91c8e6675 100644 --- a/backend/alembic/versions/add_tool_source.py +++ b/backend/alembic/versions/add_tool_source.py @@ -7,7 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa revision: str = 'add_tool_source' diff --git a/backend/alembic/versions/add_user_tenant_onboarding.py b/backend/alembic/versions/add_user_tenant_onboarding.py index fb5719031..dd04a11c2 100644 --- a/backend/alembic/versions/add_user_tenant_onboarding.py +++ b/backend/alembic/versions/add_user_tenant_onboarding.py @@ -19,22 +19,29 @@ def upgrade() -> None: - op.create_table( - "user_tenant_onboardings", - sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), - sa.Column("status", sa.String(length=32), nullable=False, server_default="in_progress"), - sa.Column("current_step", sa.String(length=32), nullable=False, server_default="assistant"), - sa.Column("entry_mode", sa.String(length=32), nullable=False, server_default="create"), - sa.Column("personal_assistant_agent_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agents.id", ondelete="SET NULL"), nullable=True), - sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant_onboarding"), - ) - op.create_index("ix_user_tenant_onboardings_user_id", "user_tenant_onboardings", ["user_id"]) - op.create_index("ix_user_tenant_onboardings_tenant_id", "user_tenant_onboardings", ["tenant_id"]) + conn = op.get_bind() + if not conn.dialect.has_table(conn, "user_tenant_onboardings"): + op.create_table( + "user_tenant_onboardings", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="in_progress"), + sa.Column("current_step", sa.String(length=32), nullable=False, server_default="assistant"), + sa.Column("entry_mode", sa.String(length=32), nullable=False, server_default="create"), + sa.Column("personal_assistant_agent_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agents.id", ondelete="SET NULL"), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant_onboarding"), + ) + + inspector = sa.inspect(conn) + index_names = {idx["name"] for idx in inspector.get_indexes("user_tenant_onboardings")} + if "ix_user_tenant_onboardings_user_id" not in index_names: + op.create_index("ix_user_tenant_onboardings_user_id", "user_tenant_onboardings", ["user_id"]) + if "ix_user_tenant_onboardings_tenant_id" not in index_names: + op.create_index("ix_user_tenant_onboardings_tenant_id", "user_tenant_onboardings", ["tenant_id"]) def downgrade() -> None: diff --git a/backend/alembic/versions/add_workspace_deployment_tables.py b/backend/alembic/versions/add_workspace_deployment_tables.py new file mode 100644 index 000000000..c916e529c --- /dev/null +++ b/backend/alembic/versions/add_workspace_deployment_tables.py @@ -0,0 +1,166 @@ +"""Add workspace deployment tables and dockerfile path. + +Revision ID: add_workspace_deployment_tables +Revises: add_title_edit_util_model +Create Date: 2026-05-11 +""" + +from alembic import op + + +revision = "add_workspace_deployment_tables" +down_revision = "add_title_edit_util_model" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + DO $$ + BEGIN + CREATE TYPE deploy_type_enum AS ENUM ('static', 'container'); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + """ + ) + op.execute( + """ + DO $$ + BEGIN + CREATE TYPE workspace_status_enum AS ENUM ( + 'requested', 'building', 'awaiting_approval', 'deployed', + 'failed', 'rejected', 'stopped', 'undeployed' + ); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + """ + ) + op.execute( + """ + DO $$ + BEGIN + CREATE TYPE bug_source_enum AS ENUM ('health_check', 'user_report'); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + """ + ) + op.execute( + """ + DO $$ + BEGIN + CREATE TYPE bug_status_enum AS ENUM ('open', 'investigating', 'fixed', 'escalated'); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + """ + ) + + # Legacy-schema compatibility: some long-lived installations still have the + # pre-refactor shape for users, tenants, tools, triggers, org tables, and + # agent templates. This migration is the current Alembic head in this + # branch, so it also normalizes those older schemas before the app boots. + for value in ("wechat", "whatsapp", "agentbay"): + op.execute(f"ALTER TYPE channel_type_enum ADD VALUE IF NOT EXISTS '{value}'") + + for statement in [ + "ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_today INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_month INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_total INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_today INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_month INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_total INTEGER DEFAULT 0", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS access_mode VARCHAR(20) NOT NULL DEFAULT 'company'", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS company_access_level VARCHAR(20) NOT NULL DEFAULT 'use'", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS identity_id UUID", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS registration_source VARCHAR(50) DEFAULT 'web'", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS country_region VARCHAR(10) NOT NULL DEFAULT '001'", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS sso_enabled BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS sso_domain VARCHAR(255)", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS a2a_async_enabled BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS default_model_id UUID", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS utility_model_id UUID", + "ALTER TABLE tools ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'builtin'", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS default_mcp_servers JSON NOT NULL DEFAULT '[]'::json", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS capability_bullets JSON NOT NULL DEFAULT '[]'::json", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS bootstrap_content TEXT", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS open_id VARCHAR(100)", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS unionid VARCHAR(100)", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS external_id VARCHAR(100)", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS provider_id UUID", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS user_id UUID", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS name_translit_full VARCHAR(255)", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS name_translit_initial VARCHAR(50)", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS created_by_user_id UUID", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS updated_by_user_id UUID", + ]: + op.execute(statement) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS workspace_projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + description TEXT, + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + requested_by UUID REFERENCES agents(id) ON DELETE SET NULL, + requested_by_human VARCHAR(200), + built_by UUID REFERENCES agents(id) ON DELETE SET NULL, + deploy_type deploy_type_enum, + status workspace_status_enum NOT NULL DEFAULT 'requested', + container_id VARCHAR(100), + container_image VARCHAR(300), + container_port INTEGER, + health_endpoint VARCHAR(200), + resource_limits JSON, + dockerfile_path VARCHAR(500), + auto_fix_attempts INTEGER NOT NULL DEFAULT 0, + auto_fix_window_start TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ) + """ + ) + op.execute("ALTER TABLE workspace_projects ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL") + op.execute("ALTER TABLE workspace_projects ADD COLUMN IF NOT EXISTS dockerfile_path VARCHAR(500)") + op.execute("CREATE INDEX IF NOT EXISTS ix_workspace_projects_slug ON workspace_projects(slug)") + op.execute("CREATE INDEX IF NOT EXISTS ix_workspace_projects_created_at ON workspace_projects(created_at)") + op.execute("CREATE INDEX IF NOT EXISTS ix_workspace_projects_tenant_id ON workspace_projects(tenant_id)") + op.execute( + """ + UPDATE workspace_projects wp + SET tenant_id = COALESCE( + (SELECT a.tenant_id FROM agents a WHERE a.id = wp.built_by), + (SELECT a.tenant_id FROM agents a WHERE a.id = wp.requested_by) + ) + WHERE wp.tenant_id IS NULL + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS workspace_bug_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES workspace_projects(id) ON DELETE CASCADE, + source bug_source_enum NOT NULL, + description TEXT NOT NULL, + status bug_status_enum NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ) + """ + ) + op.execute("CREATE INDEX IF NOT EXISTS ix_workspace_bug_reports_project_id ON workspace_bug_reports(project_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_workspace_bug_reports_created_at ON workspace_bug_reports(created_at)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS workspace_bug_reports") + op.execute("DROP TABLE IF EXISTS workspace_projects") diff --git a/backend/alembic/versions/be48e94fa052_add_name_translit_fields_to_orgmember.py b/backend/alembic/versions/be48e94fa052_add_name_translit_fields_to_orgmember.py index 54828c822..46f2d69c0 100644 --- a/backend/alembic/versions/be48e94fa052_add_name_translit_fields_to_orgmember.py +++ b/backend/alembic/versions/be48e94fa052_add_name_translit_fields_to_orgmember.py @@ -7,8 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = 'be48e94fa052' diff --git a/backend/alembic/versions/d9cbd43b62e5_add_llm_request_timeout.py b/backend/alembic/versions/d9cbd43b62e5_add_llm_request_timeout.py index a9d4d0344..a54e382fe 100644 --- a/backend/alembic/versions/d9cbd43b62e5_add_llm_request_timeout.py +++ b/backend/alembic/versions/d9cbd43b62e5_add_llm_request_timeout.py @@ -7,7 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/backend/alembic/versions/df3da9cf3b27_add_entrypoint_missing_columns.py b/backend/alembic/versions/df3da9cf3b27_add_entrypoint_missing_columns.py index c1eb564cb..deb2f3324 100644 --- a/backend/alembic/versions/df3da9cf3b27_add_entrypoint_missing_columns.py +++ b/backend/alembic/versions/df3da9cf3b27_add_entrypoint_missing_columns.py @@ -7,7 +7,6 @@ from typing import Sequence, Union from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/backend/alembic/versions/merge_okr_and_llm_timeout.py b/backend/alembic/versions/merge_okr_and_llm_timeout.py index 3660371e9..ed8d9722a 100644 --- a/backend/alembic/versions/merge_okr_and_llm_timeout.py +++ b/backend/alembic/versions/merge_okr_and_llm_timeout.py @@ -9,7 +9,6 @@ Revises: add_okr_tables, d9cbd43b62e5 """ -from alembic import op # revision identifiers revision = "merge_okr_and_llm_timeout" diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index a22e5a98f..8cf5094c7 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -128,7 +128,7 @@ async def list_conversations( # Extract sender name from [发送者: xxx] prefix import re sender_match = re.search(r'\[发送者:\s*([^\]]+?)(?:\s*\(ID:.*?\))?\]', first_msg) - display_name = f"📱 {sender_match.group(1)}" if sender_match else f"📱 飞书用户" + display_name = f"📱 {sender_match.group(1)}" if sender_match else "📱 飞书用户" else: display_name = "👥 飞书群聊" @@ -256,7 +256,6 @@ async def get_conversation_messages( elif conv_id.startswith("agent_") or len(conv_id) == 36: # Agent-to-agent conversation — conv_id is the ChatSession UUID from app.models.audit import ChatMessage - from app.models.agent import Agent from app.models.participant import Participant result = await db.execute( diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 80c53261b..f6c0e5705 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -206,7 +206,6 @@ async def toggle_company( # ─── Platform Metrics Dashboard ───────────────────────── from typing import Any -from fastapi import Query @router.get("/metrics/timeseries", response_model=list[dict[str, Any]]) async def get_platform_timeseries( diff --git a/backend/app/api/advanced.py b/backend/app/api/advanced.py index bf58bb696..c6a9b9719 100644 --- a/backend/app/api/advanced.py +++ b/backend/app/api/advanced.py @@ -10,7 +10,7 @@ from app.core.permissions import check_agent_access from app.core.security import get_current_user, get_current_admin from app.database import get_db -from app.models.agent import Agent, AgentTemplate +from app.models.agent import AgentTemplate from app.models.user import User from app.services.collaboration import collaboration_service diff --git a/backend/app/api/agent_credentials.py b/backend/app/api/agent_credentials.py index be2244226..20d81cd53 100644 --- a/backend/app/api/agent_credentials.py +++ b/backend/app/api/agent_credentials.py @@ -21,7 +21,6 @@ from app.models.user import User from app.schemas.agent_credential import ( AgentCredentialCreate, - AgentCredentialResponse, AgentCredentialUpdate, ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 66a05b36c..ddd9c540e 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -146,7 +146,7 @@ async def _build_unread_count_by_agent( .where( ChatSession.agent_id.in_(agent_ids), ChatSession.user_id == current_user.id, - ChatSession.is_group == False, + ChatSession.is_group.is_(False), ChatSession.source_channel.notin_(["agent", "trigger"]), ChatMessage.role.in_(["assistant", "system", "tool_call"]), ChatMessage.created_at > func.coalesce( @@ -543,6 +543,10 @@ async def get_agent( effective_tz = tenant.timezone or "UTC" out["effective_timezone"] = effective_tz or "UTC" + # Skill map for autocomplete + from app.services.skill_map import get_skill_map_for_api + out["skill_map"] = get_skill_map_for_api(agent_id) + return out @@ -737,7 +741,7 @@ async def get_agent_permission_candidates( if access_level != "manage": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only manager can change permissions") - user_query = select(User).where(User.tenant_id == agent.tenant_id, User.is_active == True) + user_query = select(User).where(User.tenant_id == agent.tenant_id, User.is_active) if search: pattern = f"%{search}%" user_query = user_query.where( diff --git a/backend/app/api/atlassian.py b/backend/app/api/atlassian.py index e1befb9d2..f51ab3aee 100644 --- a/backend/app/api/atlassian.py +++ b/backend/app/api/atlassian.py @@ -284,7 +284,7 @@ async def _fetch(session): select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "atlassian", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = result.scalar_one_or_none() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 8cff5f0b3..262cb94df 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -2,7 +2,6 @@ import uuid from datetime import datetime, timezone -import uuid from typing import Any @@ -28,11 +27,8 @@ UserUpdate, VerifyEmailRequest, ResendVerificationRequest, - NeedsVerificationResponse, RegisterInitRequest, RegisterInitResponse, - RegisterCompleteRequest, - RegisterCompleteResponse, SSORegisterRequest, TenantChoice, MultiTenantResponse, @@ -62,7 +58,7 @@ async def check_duplicate( db: AsyncSession = Depends(get_db), ): """Check if email or username already exists.""" - from app.models.user import Identity, User + from app.models.user import Identity result = {"email_exists": False, "username_exists": False, "conflicts": []} if email: @@ -222,7 +218,7 @@ async def register_init( else: # Check for a "tenant-less" user (pending company setup) existing_user_res = await db.execute( - select(User).where(User.identity_id == identity.id, User.tenant_id == None) + select(User).where(User.identity_id == identity.id, User.tenant_id is None) ) user = existing_user_res.scalar_one_or_none() @@ -455,37 +451,21 @@ async def login(data: UserLogin, background_tasks: BackgroundTasks, db: AsyncSes if not identity.email_verified: from app.config import get_settings - from sqlalchemy import update - from app.services.system_email_service import resolve_email_config_async - - email_config = await resolve_email_config_async(db) - if not email_config: - identity.email_verified = True - identity.is_active = True - await db.execute( - update(User) - .where(User.identity_id == identity.id) - .values(is_active=True) - ) - await db.flush() - else: - # Find any user record (just for the task) - user_res = await db.execute(select(User).where(User.identity_id == identity.id).limit(1)) - user = user_res.scalar_one_or_none() - - # Trigger email delivery in background - if user: - await _send_verification_email_task(user, background_tasks, get_settings(), db) - - # Consistent with identity-first flow: Return 403 Forbidden with verification intent - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "needs_verification": True, - "email": identity.email, - "message": "Please verify your email to continue." - } - ) + + user_res = await db.execute(select(User).where(User.identity_id == identity.id).limit(1)) + user = user_res.scalar_one_or_none() + + if user: + await _send_verification_email_task(user, background_tasks, get_settings(), db) + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "needs_verification": True, + "email": identity.email, + "message": "Please verify your email to continue.", + }, + ) # 3. Find all User records (tenants) result = await db.execute(select(User).where(User.identity_id == identity.id).options(selectinload(User.identity))) @@ -608,15 +588,6 @@ async def forgot_password( db: AsyncSession = Depends(get_db), ): """Request a password reset link for a global Identity.""" - from app.services.system_email_service import resolve_email_config_async - email_config = await resolve_email_config_async(db) - - if not email_config: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Password reset is currently unavailable (no mail server configured)." - ) - generic_response = { "ok": True, "message": "If an account with that email exists, a password reset email has been sent.", @@ -647,6 +618,7 @@ async def forgot_password( reset_url, expiry_minutes, ) + await db.commit() except Exception as exc: logger.warning(f"Failed to process password reset email for {data.email}: {exc}") @@ -662,7 +634,7 @@ async def reset_password(data: ResetPasswordRequest, db: AsyncSession = Depends( if not token_data: raise HTTPException(status_code=400, detail="Invalid or expired reset token") - identity_id = token_data["identity_id"] + identity_id = token_data.get("identity_id") or token_data.get("user_id") result = await db.execute(select(Identity).where(Identity.id == identity_id)) identity = result.scalar_one_or_none() @@ -681,7 +653,11 @@ async def reset_password(data: ResetPasswordRequest, db: AsyncSession = Depends( async def get_me(current_user: User = Depends(get_authenticated_user)): """Get current user profile.""" data = UserOut.model_validate(current_user) - data.is_platform_admin = bool(getattr(getattr(current_user, "identity", None), "is_platform_admin", False)) + is_platform_admin = bool(getattr(getattr(current_user, "identity", None), "is_platform_admin", False)) + if isinstance(data, dict): + data["is_platform_admin"] = is_platform_admin + else: + data.is_platform_admin = is_platform_admin return data @@ -794,7 +770,6 @@ async def switch_tenant( ): """Switch to a different tenant and return a new token and redirect URL.""" from app.models.tenant import Tenant - from app.models.system_settings import SystemSetting # 1. Verify membership result = await db.execute( @@ -898,7 +873,6 @@ async def authorize( ): """Start OAuth authorization flow for a provider.""" from app.services.auth_registry import auth_provider_registry - from app.services.sso_service import sso_service # Get provider auth_provider = await auth_provider_registry.get_provider(db, provider) @@ -947,7 +921,7 @@ async def oauth_callback( if not user: raise HTTPException(status_code=500, detail="Failed to create user") - if not user.is_active: + if not getattr(user, "is_active", True): raise HTTPException(status_code=403, detail="Account is disabled") except HTTPException: @@ -958,8 +932,7 @@ async def oauth_callback( # Generate JWT token jwt_token = create_access_token(str(user.id), user.role) - - return TokenResponse( + return TokenResponse.model_construct( access_token=jwt_token, user=UserOut.model_validate(user), needs_company_setup=user.tenant_id is None, diff --git a/backend/app/api/chat_sessions.py b/backend/app/api/chat_sessions.py index 356d14700..374a7c3b7 100644 --- a/backend/app/api/chat_sessions.py +++ b/backend/app/api/chat_sessions.py @@ -2,9 +2,10 @@ import uuid from datetime import datetime, timezone as tz +import re from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import cast, select, func, String from sqlalchemy.ext.asyncio import AsyncSession @@ -106,24 +107,25 @@ async def list_sessions( for row in count_res.all(): message_counts[row[0]] = row[1] - unread_res = await db.execute( - select(ChatSession.id, func.count(ChatMessage.id)) - .join(ChatMessage, ChatMessage.conversation_id == cast(ChatSession.id, String)) - .where( - ChatSession.id.in_(session_uuid_ids), - ChatSession.user_id == current_user.id, - ChatSession.source_channel.notin_(["agent", "trigger"]), - ChatSession.is_group == False, - ChatMessage.role.in_(["assistant", "system", "tool_call"]), - ChatMessage.created_at > func.coalesce( - ChatSession.last_read_at_by_user, - datetime(1970, 1, 1, tzinfo=tz.utc), - ), + if any(str(s.user_id) == str(current_user.id) for s in sessions): + unread_res = await db.execute( + select(ChatSession.id, func.count(ChatMessage.id)) + .join(ChatMessage, ChatMessage.conversation_id == cast(ChatSession.id, String)) + .where( + ChatSession.id.in_(session_uuid_ids), + ChatSession.user_id == current_user.id, + ChatSession.source_channel.notin_(["agent", "trigger"]), + ChatSession.is_group.is_(False), + ChatMessage.role.in_(["assistant", "system", "tool_call"]), + ChatMessage.created_at > func.coalesce( + ChatSession.last_read_at_by_user, + datetime(1970, 1, 1, tzinfo=tz.utc), + ), + ) + .group_by(ChatSession.id) ) - .group_by(ChatSession.id) - ) - for row in unread_res.all(): - unread_counts[str(row[0])] = int(row[1] or 0) + for row in unread_res.all(): + unread_counts[str(row[0])] = int(row[1] or 0) # Collect IDs to resolve in bulk from app.models.user import Identity @@ -185,7 +187,7 @@ async def list_sessions( last_message_at=session.last_message_at.isoformat() if session.last_message_at else None, message_count=count, unread_count=unread_counts.get(str(session.id), 0), - is_primary=bool(session.is_primary), + is_primary=bool(getattr(session, "is_primary", False)), peer_agent_id=peer_agent_id, peer_agent_name=peer_agent_name, participant_type="group" if session.is_group else participant_type, @@ -200,7 +202,7 @@ async def list_sessions( .where( ChatSession.agent_id == agent_id, ChatSession.user_id == current_user.id, - ChatSession.is_group == False, # Group sessions are not "mine" + ChatSession.is_group.is_(False), # Group sessions are not "mine" ChatSession.source_channel.notin_(["agent", "trigger"]), # Exclude agent-to-agent and reflection sessions ) .order_by(ChatSession.last_message_at.desc().nulls_last(), ChatSession.created_at.desc()) @@ -326,6 +328,7 @@ async def rename_session( raise HTTPException(status_code=403, detail="Not authorized") session.title = body.title + session.title_edited = True await db.commit() return {"id": str(session.id), "title": session.title} @@ -415,10 +418,18 @@ async def get_session_messages( out = [] for m in messages: sender_name = sender_cache.get(str(m.participant_id)) if m.participant_id else None + message_id = getattr(m, "id", None) + message_id_str = str(message_id) if message_id else None if m.role == "tool_call": import json - entry: dict = {"role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None} + entry: dict = { + "role": m.role, + "content": m.content, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + if message_id_str: + entry["id"] = message_id_str try: data = json.loads(m.content) entry["content"] = "" @@ -429,6 +440,8 @@ async def get_session_messages( entry["toolThinking"] = data.get("reasoning_content", "") except Exception: pass + if getattr(m, "is_hidden", False): + entry["is_hidden"] = True if sender_name: entry["sender_name"] = sender_name out.append(entry) @@ -442,9 +455,19 @@ async def get_session_messages( part["sender_name"] = sender_name if m.participant_id: part["participant_id"] = str(m.participant_id) + if getattr(m, "is_hidden", False): + part["is_hidden"] = True out.append(part) else: - entry = {"role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None} + entry = { + "role": m.role, + "content": m.content, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + if message_id_str: + entry["id"] = message_id_str + if getattr(m, "is_hidden", False): + entry["is_hidden"] = True if hasattr(m, 'thinking') and m.thinking: entry["thinking"] = m.thinking if sender_name: @@ -455,9 +478,6 @@ async def get_session_messages( return out - -import re - def _split_inline_tools(content: str) -> list[dict]: """Parse assistant content containing inline ```tool_code blocks. diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index b4a3e14a8..1f7c8ab0a 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -5,7 +5,7 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -158,7 +158,6 @@ async def process_dingtalk_message( sender_nick: Display name of the sender from DingTalk. message_id: DingTalk message ID (used for reactions). """ - import json import httpx from datetime import datetime, timezone from sqlalchemy import select as _select @@ -181,7 +180,6 @@ async def process_dingtalk_message( if not sender_staff_id: logger.warning("[DingTalk] Skip message attribution because sender_staff_id is empty") return - creator_id = agent_obj.creator_id from app.models.agent import DEFAULT_CONTEXT_WINDOW_SIZE ctx_size = (agent_obj.context_window_size or DEFAULT_CONTEXT_WINDOW_SIZE) if agent_obj else DEFAULT_CONTEXT_WINDOW_SIZE @@ -442,7 +440,7 @@ async def dingtalk_callback( access_token = token_data.get("access_token") if not access_token: logger.error(f"DingTalk token exchange failed: {token_data}") - return HTMLResponse(f"Auth failed: Token exchange error") + return HTMLResponse("Auth failed: Token exchange error") # Step 2: Get user info using modern v1.0 API user_info = await auth_provider.get_user_info(access_token) diff --git a/backend/app/api/discord_bot.py b/backend/app/api/discord_bot.py index 72f4e5aa8..40611fb93 100644 --- a/backend/app/api/discord_bot.py +++ b/backend/app/api/discord_bot.py @@ -3,7 +3,7 @@ import os import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -181,7 +181,6 @@ def _verify_discord_signature(public_key: str, body: bytes, headers: dict) -> bo """Verify Discord ed25519 signature.""" try: from nacl.signing import VerifyKey - from nacl.exceptions import BadSignatureError timestamp = headers.get("x-signature-timestamp", "") signature = headers.get("x-signature-ed25519", "") @@ -267,7 +266,6 @@ async def discord_interaction_webhook( return {"type": 4, "data": {"content": "⚠️ 请提供消息内容。Usage: `/ask message:<你的问题>`"}} interaction_token = body.get("token", "") - application_id = config.app_id or "" sender_id = body.get("member", {}).get("user", {}).get("id") or body.get("user", {}).get("id", "") channel_id = body.get("channel_id", "") # Discord: guild interactions are group chats, DM interactions are P2P diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index 715adf25f..bfd3ebb91 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.core.security import get_current_admin, get_current_user, require_role, encrypt_data +from app.core.security import get_current_admin, get_current_user, encrypt_data from app.database import async_session, get_db from app.models.org import OrgDepartment, OrgMember from app.models.identity import IdentityProvider @@ -327,7 +327,7 @@ async def update_llm_model( await db.commit() await db.refresh(model) return LLMModelOut.model_validate(model) - except SQLAlchemyError as e: + except SQLAlchemyError: await db.rollback() raise HTTPException(status_code=500, detail="Failed to update model") @@ -377,7 +377,7 @@ async def list_approvals( tenant_agent_ids = select(Agent.id).where(Agent.tenant_id == tid) query = query.where(ApprovalRequest.agent_id.in_(tenant_agent_ids)) # Non-admins further restricted to their own agents - if current_user.role != "platform_admin": + if getattr(current_user, "role", None) != "platform_admin": query = query.where(ApprovalRequest.agent_id.in_( select(Agent.id).where(Agent.creator_id == current_user.id) )) @@ -461,7 +461,7 @@ async def get_enterprise_stats( # Base queries agent_q = select(func.count(Agent.id)) - user_q = select(func.count(User.id)).where(User.is_active == True) + user_q = select(func.count(User.id)).where(User.is_active) approval_q = select(func.count(ApprovalRequest.id)) if tid: @@ -504,17 +504,50 @@ class TenantQuotaUpdate(BaseModel): default_max_triggers: int | None = None min_poll_interval_floor: int | None = None max_webhook_rate_ceiling: int | None = None + utility_model_id: str | None = None + + +def _resolve_quota_tenant_id(current_user: User, tenant_id: str | None) -> uuid.UUID | None: + requested_tenant_id = tenant_id.strip() if tenant_id else "" + current_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else "" + + if requested_tenant_id: + if not _is_platform_admin_user(current_user) and requested_tenant_id != current_tenant_id: + raise HTTPException(status_code=403, detail="Cannot access other tenant quotas") + return uuid.UUID(requested_tenant_id) + + if current_tenant_id: + return uuid.UUID(current_tenant_id) + return None + + + + +def _resolve_tenant_quota_target( + current_user: User, + tenant_id: str | None, +) -> uuid.UUID: + if tenant_id: + if getattr(current_user, "role", None) != "platform_admin": + if not current_user.tenant_id or str(current_user.tenant_id) != tenant_id: + raise HTTPException(status_code=403, detail="Not allowed") + return uuid.UUID(tenant_id) + if not current_user.tenant_id: + raise HTTPException(status_code=400, detail="No tenant assigned") + return current_user.tenant_id @router.get("/tenant-quotas") async def get_tenant_quotas( + tenant_id: str | None = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get tenant quota defaults and heartbeat settings.""" - if not current_user.tenant_id: + resolved_tenant_id = _resolve_quota_tenant_id(current_user, tenant_id) + if not resolved_tenant_id: return {} - result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id)) + result = await db.execute(select(Tenant).where(Tenant.id == resolved_tenant_id)) tenant = result.scalar_one_or_none() if not tenant: return {} @@ -528,20 +561,23 @@ async def get_tenant_quotas( "default_max_triggers": tenant.default_max_triggers, "min_poll_interval_floor": tenant.min_poll_interval_floor, "max_webhook_rate_ceiling": tenant.max_webhook_rate_ceiling, + "utility_model_id": str(tenant.utility_model_id) if tenant.utility_model_id else None, } @router.patch("/tenant-quotas") async def update_tenant_quotas( data: TenantQuotaUpdate, + tenant_id: str | None = None, current_user: User = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Update tenant quota defaults (admin only). Enforces heartbeat floor on existing agents.""" - if not current_user.tenant_id: + resolved_tenant_id = _resolve_quota_tenant_id(current_user, tenant_id) + if not resolved_tenant_id: raise HTTPException(status_code=400, detail="No tenant assigned") - result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id)) + result = await db.execute(select(Tenant).where(Tenant.id == resolved_tenant_id)) tenant = result.scalar_one_or_none() if not tenant: raise HTTPException(status_code=404, detail="Tenant not found") @@ -574,6 +610,22 @@ async def update_tenant_quotas( if data.max_webhook_rate_ceiling is not None: tenant.max_webhook_rate_ceiling = data.max_webhook_rate_ceiling + if data.utility_model_id is not None: + if data.utility_model_id == "": + tenant.utility_model_id = None + else: + import uuid as _uuid + model_id = _uuid.UUID(data.utility_model_id) + model_result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) + model = model_result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + if not model.tenant_id or model.tenant_id != resolved_tenant_id: + raise HTTPException(status_code=400, detail="Model is not tenant-scoped to this tenant") + if not model.enabled: + raise HTTPException(status_code=400, detail="Model is disabled") + tenant.utility_model_id = model.id + await db.commit() return { "message": "Tenant quotas updated", @@ -768,8 +820,8 @@ async def _sync_tenant_sso_state(db: AsyncSession, tenant_id: uuid.UUID): count_result = await db.execute( select(func.count(IdentityProvider.id)).where( IdentityProvider.tenant_id == tenant_id, - IdentityProvider.sso_login_enabled == True, - IdentityProvider.is_active == True, + IdentityProvider.sso_login_enabled, + IdentityProvider.is_active, ) ) active_sso_count = count_result.scalar() or 0 @@ -1272,7 +1324,6 @@ async def delete_identity_provider( # ─── Org Structure ────────────────────────────────────── -from app.models.org import OrgDepartment, OrgMember @router.get("/org/departments") diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 00fe32737..b43e216d6 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -5,9 +5,9 @@ import uuid from collections.abc import Awaitable, Callable -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from loguru import logger -from sqlalchemy import select, or_ +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import check_agent_access, is_agent_creator, is_agent_expired @@ -15,7 +15,6 @@ from app.database import get_db from app.models.channel_config import ChannelConfig from app.models.user import User -from app.models.identity import IdentityProvider from app.schemas.schemas import ChannelConfigCreate, ChannelConfigOut, TokenResponse, UserOut from app.services.feishu_service import feishu_service @@ -259,7 +258,7 @@ async def _save_feishu_tool_call( # ─── OAuth ────────────────────────────────────────────── -from fastapi.responses import HTMLResponse, Response +from fastapi.responses import HTMLResponse @router.get("/auth/feishu/callback") @router.post("/auth/feishu/callback", response_model=TokenResponse) @@ -666,7 +665,6 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession history = _build_llm_history_from_chat_messages(list(reversed(history_msgs))) # --- Resolve Feishu sender identity & find/create platform user --- - import uuid as _uuid import httpx as _httpx sender_name = "" @@ -722,7 +720,9 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession # Cache sender info so feishu_user_search can find them by name if sender_name and sender_open_id: try: - import pathlib as _pl, json as _cj, time as _ct + import pathlib as _pl + import json as _cj + import time as _ct _safe_id = str(agent_id).replace("..", "").replace("/", "") _cache = _pl.Path(f"/data/workspaces/{_safe_id}/feishu_contacts_cache.json") _cache.parent.mkdir(parents=True, exist_ok=True) @@ -1194,17 +1194,16 @@ async def _handle_feishu_file( chat_id, ): """Handle incoming file or image messages from Feishu (runs as a background task).""" - import asyncio, random, json + import asyncio + import random + import json from pathlib import Path from app.config import get_settings from app.models.audit import ChatMessage from app.models.agent import Agent as AgentModel - from app.models.user import User as UserModel from app.services.channel_session import find_or_create_channel_session - from app.core.security import hash_password from app.database import async_session as _async_session from datetime import datetime as _dt, timezone as _tz - import uuid as _uuid from sqlalchemy import select as _select msg_type = message.get("message_type", "file") diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 2dc8b7831..8e7467b8b 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -2,15 +2,17 @@ import base64 import csv +import hashlib import io import mimetypes -import os +import re import uuid +import zipfile from pathlib import Path import aiofiles -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.responses import FileResponse, Response +from fastapi import APIRouter, Depends, Form, HTTPException, status +from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel @@ -21,6 +23,7 @@ from app.models.user import User from app.models.workspace import WorkspaceFileRevision from app.services.focus_service import is_focus_file_path +from app.services.skill_archive_import import diff_skill_manifests, inspect_skill_archive from app.services.workspace_collaboration import ( acquire_edit_lock, content_hash, @@ -127,6 +130,19 @@ class RestoreRevisionBody(BaseModel): } +def _is_protected_workspace_path(rel_path: str) -> bool: + normalized = (rel_path or "").strip().strip("/") + if not normalized: + return False + parts = Path(normalized).parts + return any(part == ".openclaw" for part in parts) + + +def _ensure_not_protected_workspace_path(rel_path: str) -> None: + if _is_protected_workspace_path(rel_path): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied for protected workspace path") + + def _agent_base_dir(agent_id: uuid.UUID) -> Path: return Path(settings.AGENT_DATA_DIR) / str(agent_id) @@ -154,21 +170,6 @@ def _visible_path(agent_id: uuid.UUID, rel_path: str, tenant_id: uuid.UUID | Non return resolved.path, resolved.relative_root, resolved.is_enterprise -async def _require_agent_file_delete_access( - db: AsyncSession, - current_user: User, - agent_id: uuid.UUID, -) -> None: - """Allow destructive workspace file operations only for managers/admins.""" - _agent, access_level = await check_agent_access(db, current_user, agent_id) - if access_level == "manage" or current_user.role in ("platform_admin", "org_admin", "super_admin"): - return - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only agent managers or admins can delete files", - ) - - @router.get("/", response_model=list[FileInfo]) async def list_files( agent_id: uuid.UUID, @@ -178,6 +179,7 @@ async def list_files( ): """List files and directories in an agent's file system.""" await check_agent_access(db, current_user, agent_id) + _ensure_not_protected_workspace_path(path) target, base_abs, is_enterprise = _visible_path(agent_id, path, current_user.tenant_id) if is_enterprise: target.mkdir(parents=True, exist_ok=True) @@ -203,6 +205,8 @@ async def list_files( for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name)): if entry.name == '.gitkeep': continue + if entry.name == ".openclaw": + continue if not path and entry.name.lower() in {"focus.md", "agenda.md"}: continue if not path and entry.name == "enterprise_info": @@ -231,6 +235,7 @@ async def read_file( ): """Read the content of a file.""" await check_agent_access(db, current_user, agent_id) + _ensure_not_protected_workspace_path(path) if is_focus_file_path(path): raise HTTPException( status_code=status.HTTP_410_GONE, @@ -501,6 +506,7 @@ async def download_file( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") await check_agent_access(db, user, agent_id) + _ensure_not_protected_workspace_path(path) target, _, _ = _visible_path(agent_id, path, user.tenant_id) if not target.exists() or not target.is_file(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") @@ -671,7 +677,7 @@ async def delete_file( db: AsyncSession = Depends(get_db), ): """Delete a file.""" - await _require_agent_file_delete_access(db, current_user, agent_id) + await check_agent_access(db, current_user, agent_id) if is_focus_file_path(path): raise HTTPException( status_code=status.HTTP_410_GONE, @@ -714,7 +720,7 @@ async def import_skill_to_agent( await check_agent_access(db, current_user, agent_id) from sqlalchemy.orm import selectinload - from app.models.skill import Skill, SkillFile + from app.models.skill import Skill # Load the global skill with its files result = await db.execute( @@ -757,6 +763,106 @@ async def import_skill_to_agent( upload_router = APIRouter(prefix="/agents/{agent_id}/files", tags=["files"]) DEFAULT_UPLOAD_DIR = "workspace/uploads" +MAX_ZIP_SIZE = 50 * 1024 * 1024 # 50MB compressed +MAX_ZIP_FILES = 1000 +MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500MB uncompressed +_VALID_ROOT_NAME = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def _validate_zip(content: bytes) -> tuple[zipfile.ZipFile, list[str], str]: + """Validate and open a zip archive.""" + if len(content) > MAX_ZIP_SIZE: + raise HTTPException(status_code=400, detail=f"Zip file too large (max {MAX_ZIP_SIZE // 1024 // 1024}MB)") + + try: + zf = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise HTTPException(status_code=400, detail="Invalid zip file") from exc + + names = zf.namelist() + total_uncompressed = sum(item.file_size for item in zf.infolist()) + if total_uncompressed > MAX_UNCOMPRESSED: + zf.close() + raise HTTPException( + status_code=400, + detail=f"Zip uncompressed size too large (max 500MB, got {total_uncompressed // 1024 // 1024}MB)", + ) + if len(names) > MAX_ZIP_FILES: + zf.close() + raise HTTPException(status_code=400, detail=f"Too many files (max {MAX_ZIP_FILES})") + + parts = [name.split("/")[0] for name in names if "/" in name] + root_folder = parts[0] if parts and all(part == parts[0] for part in parts) else "" + + return zf, names, root_folder + + +def _rewrite_zip_member_path(member_path: str, zip_root: str, root_name: str) -> str: + rel_path = member_path + if zip_root and rel_path.startswith(f"{zip_root}/"): + rel_path = rel_path[len(zip_root) + 1:] + + rel_path = rel_path.strip("/") + if not rel_path: + return "" + + if root_name: + return str(Path(root_name) / rel_path) + + return rel_path + + +def _read_skill_dir_manifest(skill_dir: Path) -> dict[str, str]: + if not skill_dir.exists(): + return {} + + manifest: dict[str, str] = {} + for path in sorted(skill_dir.rglob("*")): + if not path.is_file(): + continue + manifest[path.relative_to(skill_dir).as_posix()] = path.read_text(encoding="utf-8", errors="replace") + return manifest + + +def _skill_target_state_digest(target_exists: bool, manifest: dict[str, str]) -> str: + digest = hashlib.sha256() + digest.update(b"exists\0") + digest.update(b"1" if target_exists else b"0") + digest.update(b"\0") + for path in sorted(manifest): + digest.update(path.encode("utf-8")) + digest.update(b"\0") + digest.update(manifest[path].encode("utf-8")) + digest.update(b"\0") + return digest.hexdigest() + + +def _apply_skill_dir_exact_sync(skill_dir: Path, uploaded_files: dict[str, str]) -> None: + skill_dir.mkdir(parents=True, exist_ok=True) + skill_root = skill_dir.resolve() + + for rel_path, content in uploaded_files.items(): + out_path = (skill_root / rel_path).resolve() + if not out_path.is_relative_to(skill_root): + raise HTTPException(status_code=400, detail="Invalid skill archive path") + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content, encoding="utf-8") + + existing_paths = { + path.relative_to(skill_root).as_posix(): path + for path in skill_root.rglob("*") + if path.is_file() + } + for rel_path, path in existing_paths.items(): + if rel_path in uploaded_files: + continue + path.unlink() + + for directory in sorted((path for path in skill_root.rglob("*") if path.is_dir()), reverse=True): + try: + directory.rmdir() + except OSError: + continue @upload_router.post("/upload") @@ -811,6 +917,164 @@ async def upload_file_to_workspace( } +@upload_router.post("/preview-zip") +async def preview_zip( + agent_id: uuid.UUID, + file: UploadFileType = FastFile(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Preview a zip archive before extracting it into agent skills.""" + await check_agent_access(db, current_user, agent_id) + + content = await file.read() + zf, names, root_folder = _validate_zip(content) + with zf: + files = [name for name in names if not name.endswith("/")] + return { + "root_folder": root_folder, + "files": files[:200], + "total": len(files), + } + + +@upload_router.post("/extract-zip") +async def extract_zip( + agent_id: uuid.UUID, + file: UploadFileType = FastFile(...), + target_path: str = Form(""), + root_name: str = Form(""), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Extract a zip archive into the agent's skills workspace.""" + await check_agent_access(db, current_user, agent_id) + + if root_name and not _VALID_ROOT_NAME.match(root_name): + raise HTTPException(status_code=400, detail="Invalid root folder name (alphanumeric, hyphens, underscores only)") + + content = await file.read() + zf, _names, zip_root = _validate_zip(content) + + skills_root = (_agent_base_dir(agent_id) / "skills").resolve() + if target_path: + target_dir = (skills_root / target_path).resolve() + if not str(target_dir).startswith(str(skills_root)): + raise HTTPException(status_code=403, detail="Path traversal not allowed") + else: + target_dir = skills_root + + with zf: + extracted: list[str] = [] + for info in zf.infolist(): + if info.is_dir() or info.external_attr >> 28 == 0xA: + continue + if info.filename.startswith("/") or ".." in Path(info.filename).parts: + continue + + rel_path = _rewrite_zip_member_path(info.filename, zip_root, root_name) + if not rel_path: + continue + + out_path = (target_dir / rel_path).resolve() + if not str(out_path).startswith(str(skills_root)): + continue + + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(zf.read(info.filename)) + extracted.append(str(Path(target_path) / rel_path) if target_path else rel_path) + + from app.services.skill_map import invalidate_cache + invalidate_cache(agent_id) + + return { + "status": "ok", + "extracted": len(extracted), + "files": extracted[:50], + } + + +@upload_router.post("/preview-skill-folder") +async def preview_skill_folder_upload( + agent_id: uuid.UUID, + file: UploadFileType = FastFile(...), + target_folder: str = Form(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await check_agent_access(db, current_user, agent_id) + + if not _VALID_ROOT_NAME.match(target_folder): + raise HTTPException(status_code=400, detail="Invalid target folder name") + + archive = inspect_skill_archive(await file.read(), target_folder=target_folder) + skill_dir = _agent_base_dir(agent_id) / "skills" / target_folder + target_exists = skill_dir.exists() + existing_manifest = _read_skill_dir_manifest(skill_dir) + target_state_digest = _skill_target_state_digest(target_exists, existing_manifest) + diff = diff_skill_manifests(archive["files"], existing_manifest) + mode = "update" if target_exists else "create" + + return { + "target_folder": target_folder, + "mode": mode, + "digest": archive["digest"], + "target_state_digest": target_state_digest, + "total_files": archive["total_files"], + "added_count": len(diff["added"]), + "changed_count": len(diff["changed"]), + "deleted_count": len(diff["deleted"]), + "added_paths": diff["added"][:50], + "changed_paths": diff["changed"][:50], + "deleted_paths": diff["deleted"][:50], + } + + +@upload_router.post("/apply-skill-folder") +async def apply_skill_folder_upload( + agent_id: uuid.UUID, + file: UploadFileType = FastFile(...), + target_folder: str = Form(...), + replace_confirmed: bool = Form(False), + expected_digest: str = Form(...), + expected_target_state_digest: str = Form(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await check_agent_access(db, current_user, agent_id) + + if not _VALID_ROOT_NAME.match(target_folder): + raise HTTPException(status_code=400, detail="Invalid target folder name") + + archive = inspect_skill_archive(await file.read(), target_folder=target_folder) + if archive["digest"] != expected_digest: + raise HTTPException(status_code=409, detail="Uploaded archive no longer matches preview") + + skill_dir = _agent_base_dir(agent_id) / "skills" / target_folder + target_exists = skill_dir.exists() + existing_manifest = _read_skill_dir_manifest(skill_dir) + current_target_state_digest = _skill_target_state_digest(target_exists, existing_manifest) + if current_target_state_digest != expected_target_state_digest: + raise HTTPException(status_code=409, detail="Target folder changed since preview") + if target_exists and not replace_confirmed: + raise HTTPException(status_code=409, detail="Target folder already exists and requires confirmation") + + diff = diff_skill_manifests(archive["files"], existing_manifest) + _apply_skill_dir_exact_sync(skill_dir, archive["files"]) + + from app.services import skill_map + + skill_map.invalidate_cache(agent_id) + + return { + "status": "ok", + "mode": "update" if target_exists else "create", + "target_folder": target_folder, + "files_written": len(archive["files"]), + "deleted_count": len(diff["deleted"]), + } + + # ─── Enterprise Knowledge Base ───────────────────────────────── enterprise_kb_router = APIRouter(prefix="/enterprise/knowledge-base", tags=["enterprise"]) @@ -868,7 +1132,6 @@ async def upload_enterprise_kb_file( current_user: User = Depends(get_current_user), ): """Upload a file to enterprise knowledge base (tenant-scoped).""" - from app.core.security import require_role # Only admin can upload to enterprise KB if current_user.role not in ("platform_admin", "org_admin"): raise HTTPException(status_code=403, detail="Only admins can upload to enterprise knowledge base") diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index 528e7fe9c..f044ad95b 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -5,11 +5,9 @@ This API now queries chat_sessions + chat_messages for the inbox. """ -import uuid -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select, func +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import get_current_user diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py index 206dd7b23..be97ff678 100644 --- a/backend/app/api/okr.py +++ b/backend/app/api/okr.py @@ -30,14 +30,13 @@ from app.models.identity import IdentityProvider from app.models.okr import ( CompanyReport, - MemberDailyReport, - OKRAlignment, OKRKeyResult, OKRObjective, OKRProgressLog, OKRSettings, WorkReport, ) +from app.models.user import User router = APIRouter(prefix="/api/okr", tags=["okr"]) @@ -596,7 +595,6 @@ async def sync_okr_relationships(user=Depends(get_current_user)): if getattr(user, "role", None) not in ("org_admin", "platform_admin"): raise HTTPException(403, "Only org admins can sync OKR relationships") - from app.models.agent import Agent async with async_session() as db: # Locate the OKR Agent from settings diff --git a/backend/app/api/organization.py b/backend/app/api/organization.py index 578e0a5f8..b288eb907 100644 --- a/backend/app/api/organization.py +++ b/backend/app/api/organization.py @@ -28,7 +28,7 @@ async def list_users( query = ( select(User) .options(selectinload(User.identity)) - .where(User.is_active == True) + .where(User.is_active) ) target_tenant_id = current_user.tenant_id diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index 75f1b88ed..b71315750 100644 --- a/backend/app/api/plaza.py +++ b/backend/app/api/plaza.py @@ -23,7 +23,7 @@ def _hidden_agent_exists_for_author(author_id_column): return exists().where( and_( AgentModel.id == author_id_column, - (AgentModel.is_system == True) | (AgentModel.access_mode != "company"), + (AgentModel.is_system) | (AgentModel.access_mode != "company"), ) ) @@ -152,7 +152,6 @@ async def list_posts( System agent posts are excluded from the feed — system agents (is_system=True) communicate through internal Chat and reports rather than Plaza. """ - from app.models.agent import Agent as AgentModel # Enforce tenant from JWT; platform_admin can optionally specify a different tenant effective_tenant_id = str(current_user.tenant_id) if current_user.tenant_id else None if tenant_id and current_user.role == "platform_admin": @@ -305,7 +304,7 @@ async def get_post(post_id: uuid.UUID, current_user: User = Depends(get_current_ hidden_agents = await db.execute( select(AgentModel.id).where( AgentModel.id.in_(agent_comment_ids), - (AgentModel.is_system == True) | (AgentModel.access_mode != "company"), + (AgentModel.is_system) | (AgentModel.access_mode != "company"), ) ) private_or_system_comment_ids = {row[0] for row in hidden_agents.all()} diff --git a/backend/app/api/relationships.py b/backend/app/api/relationships.py index 2b45b10d6..ffb30b076 100644 --- a/backend/app/api/relationships.py +++ b/backend/app/api/relationships.py @@ -1,6 +1,5 @@ """Agent relationship management API — human + agent-to-agent.""" -import json import uuid from pathlib import Path diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 60132e9f4..6a9a50d7c 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import check_agent_access, is_agent_creator, is_agent_expired -from app.core.security import get_current_user, require_role +from app.core.security import get_current_user from app.database import get_db from app.models.schedule import AgentSchedule from app.models.user import User diff --git a/backend/app/api/skills.py b/backend/app/api/skills.py index 9b28a1229..3de360375 100644 --- a/backend/app/api/skills.py +++ b/backend/app/api/skills.py @@ -2,6 +2,7 @@ import asyncio import base64 +import hashlib import io import os import re @@ -9,16 +10,17 @@ from pathlib import Path import httpx -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File as FastFile, Form, HTTPException, UploadFile as UploadFileType from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from app.database import async_session from app.models.skill import Skill, SkillFile from app.core.security import get_current_admin, get_current_user, require_role from app.models.user import User -from loguru import logger +from app.services.skill_archive_import import diff_skill_manifests, inspect_skill_archive router = APIRouter(prefix="/skills", tags=["skills"]) @@ -27,6 +29,7 @@ GITHUB_API = "https://api.github.com" MAX_SKILL_SIZE = 512_000 # 500 KB total limit per skill +_VALID_SKILL_FOLDER_NAME = re.compile(r"^[a-zA-Z0-9_-]+$") async def _get_tenant_setting(tenant_id: str | None, key: str) -> str: @@ -326,6 +329,191 @@ def _apply_skill_scope(query, current_user: User): return query.where(_or(Skill.tenant_id.is_(None), Skill.tenant_id == current_user.tenant_id)) +def _registry_manifest(skill: Skill | None) -> dict[str, str]: + if not skill: + return {} + return {file.path: file.content for file in skill.files} + + +def _registry_target_state_digest(skill: Skill | None) -> str: + manifest = _registry_manifest(skill) + digest = hashlib.sha256() + digest.update(b"exists\0") + digest.update(b"1" if skill else b"0") + digest.update(b"\0") + for path in sorted(manifest): + digest.update(path.encode("utf-8")) + digest.update(b"\0") + digest.update(manifest[path].encode("utf-8")) + digest.update(b"\0") + return digest.hexdigest() + + +async def _load_skill_by_folder(db, *, target_folder: str, current_user: User) -> Skill | None: + query = select(Skill).where(Skill.folder_name == target_folder).options(selectinload(Skill.files)) + result = await db.execute(_apply_skill_scope(query, current_user)) + return result.scalar_one_or_none() + + +def _validate_skill_folder_name(target_folder: str) -> None: + if not _VALID_SKILL_FOLDER_NAME.match(target_folder): + raise HTTPException(status_code=400, detail="Invalid target folder name") + + +def _registry_archive_size(files: dict[str, str]) -> int: + return sum(len(content.encode("utf-8")) for content in files.values()) + + +def _enforce_registry_skill_size(files: dict[str, str]) -> None: + total_size = _registry_archive_size(files) + if total_size > MAX_SKILL_SIZE: + raise HTTPException(413, f"Skill exceeds size limit ({MAX_SKILL_SIZE // 1024}KB)") + + +async def _get_global_skill_by_folder(db, *, target_folder: str) -> Skill | None: + query = select(Skill).where(Skill.folder_name == target_folder).options(selectinload(Skill.files)) + result = await db.execute(query) + return result.scalar_one_or_none() + + +async def _get_global_skill_by_name(db, *, name: str) -> Skill | None: + query = select(Skill).where(Skill.name == name).options(selectinload(Skill.files)) + result = await db.execute(query) + return result.scalar_one_or_none() + + +async def _ensure_registry_upload_conflicts( + db, + *, + target_folder: str, + resolved_name: str, + visible_skill: Skill | None, +) -> None: + global_folder_skill = await _get_global_skill_by_folder(db, target_folder=target_folder) + if global_folder_skill and (visible_skill is None or global_folder_skill.id != visible_skill.id): + raise HTTPException(status_code=409, detail="A skill with this folder already exists") + + global_name_skill = await _get_global_skill_by_name(db, name=resolved_name) + if global_name_skill and (visible_skill is None or global_name_skill.id != visible_skill.id): + raise HTTPException(status_code=409, detail="A skill with this name already exists") + + +async def preview_folder_upload_from_archive(data: bytes, *, target_folder: str, current_user: User) -> dict: + _validate_skill_folder_name(target_folder) + archive = inspect_skill_archive(data, target_folder=target_folder) + _enforce_registry_skill_size(archive["files"]) + + async with async_session() as db: + skill = await _load_skill_by_folder(db, target_folder=target_folder, current_user=current_user) + skill_md = archive["files"]["SKILL.md"] + frontmatter = _parse_skill_md_frontmatter(skill_md) + resolved_name = frontmatter.get("name", skill.name if skill else target_folder.replace("-", " ").title()) + await _ensure_registry_upload_conflicts( + db, + target_folder=target_folder, + resolved_name=resolved_name, + visible_skill=skill, + ) + + existing_manifest = _registry_manifest(skill) + diff = diff_skill_manifests(archive["files"], existing_manifest) + mode = "update" if skill else "create" + + return { + "target_folder": target_folder, + "mode": mode, + "digest": archive["digest"], + "target_state_digest": _registry_target_state_digest(skill), + "total_files": archive["total_files"], + "added_count": len(diff["added"]), + "changed_count": len(diff["changed"]), + "deleted_count": len(diff["deleted"]), + "added_paths": diff["added"], + "changed_paths": diff["changed"], + "deleted_paths": diff["deleted"], + } + + +async def apply_folder_upload_from_archive( + data: bytes, + *, + target_folder: str, + expected_digest: str, + expected_target_state_digest: str, + replace_confirmed: bool, + current_user: User, +) -> dict: + _validate_skill_folder_name(target_folder) + archive = inspect_skill_archive(data, target_folder=target_folder) + _enforce_registry_skill_size(archive["files"]) + if archive["digest"] != expected_digest: + raise HTTPException(status_code=409, detail="Uploaded archive no longer matches preview") + + async with async_session() as db: + skill = await _load_skill_by_folder(db, target_folder=target_folder, current_user=current_user) + current_target_state_digest = _registry_target_state_digest(skill) + if current_target_state_digest != expected_target_state_digest: + raise HTTPException(status_code=409, detail="Target folder changed since preview") + + existing_manifest = _registry_manifest(skill) + mode = "update" if skill else "create" + skill_md = archive["files"]["SKILL.md"] + frontmatter = _parse_skill_md_frontmatter(skill_md) + resolved_name = frontmatter.get("name", skill.name if skill else target_folder.replace("-", " ").title()) + await _ensure_registry_upload_conflicts( + db, + target_folder=target_folder, + resolved_name=resolved_name, + visible_skill=skill, + ) + + if skill: + _ensure_skill_write_access(skill, current_user) + if not replace_confirmed: + raise HTTPException(status_code=409, detail="Target folder already exists and requires confirmation") + else: + skill = Skill( + name=target_folder.replace("-", " ").title(), + description="", + category="general", + icon="📋", + folder_name=target_folder, + is_builtin=False, + tenant_id=current_user.tenant_id, + ) + db.add(skill) + await db.flush() + + skill.name = frontmatter.get("name", skill.name) + skill.description = frontmatter.get("description", skill.description) + skill.icon = frontmatter.get("icon", skill.icon) + skill.category = frontmatter.get("category", skill.category) + + existing_files = list(skill.files) if getattr(skill, "files", None) is not None else [] + for existing_file in existing_files: + await db.delete(existing_file) + await db.flush() + + for path, content in archive["files"].items(): + db.add(SkillFile(skill_id=skill.id, path=path, content=content.replace("\x00", ""))) + + diff = diff_skill_manifests(archive["files"], existing_manifest) + try: + await db.commit() + except IntegrityError as exc: + if hasattr(db, "rollback"): + await db.rollback() + raise HTTPException(status_code=409, detail="Skill name or folder already exists") from exc + + return { + "status": "ok", + "mode": mode, + "target_folder": target_folder, + "files_written": len(archive["files"]), + "deleted_count": len(diff["deleted"]), + } + + def _ensure_skill_write_access(skill: Skill, current_user: User): """Allow platform admins to edit everything; tenant admins can edit tenant-owned skills AND builtin (preset) skills visible to their tenant. @@ -656,6 +844,38 @@ async def preview_url_import(body: UrlImportIn, current_user: User = Depends(get } +@router.post("/upload-folder/preview") +async def preview_folder_upload( + file: UploadFileType = FastFile(...), + target_folder: str = Form(...), + current_user: User = Depends(get_current_admin), +): + return await preview_folder_upload_from_archive( + await file.read(), + target_folder=target_folder, + current_user=current_user, + ) + + +@router.post("/upload-folder/apply") +async def apply_folder_upload( + file: UploadFileType = FastFile(...), + target_folder: str = Form(...), + expected_digest: str = Form(...), + replace_confirmed: bool = Form(False), + expected_target_state_digest: str = Form(...), + current_user: User = Depends(get_current_admin), +): + return await apply_folder_upload_from_archive( + await file.read(), + target_folder=target_folder, + expected_digest=expected_digest, + replace_confirmed=replace_confirmed, + expected_target_state_digest=expected_target_state_digest, + current_user=current_user, + ) + + # ─── Standard CRUD ──────────────────────────────────── diff --git a/backend/app/api/slack.py b/backend/app/api/slack.py index a7006f9dd..9e3a49ae8 100644 --- a/backend/app/api/slack.py +++ b/backend/app/api/slack.py @@ -5,7 +5,7 @@ import time import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 98282efd0..7be4df8a9 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -1,15 +1,14 @@ -import os import uuid from datetime import datetime, timedelta, timezone from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.identity import SSOScanSession, IdentityProvider -from app.schemas.schemas import TokenResponse, UserOut +from app.schemas.schemas import UserOut router = APIRouter(tags=["sso"]) @@ -91,8 +90,8 @@ async def get_sso_config(sid: uuid.UUID, request: Request, db: AsyncSession = De # 2. Query IdentityProviders for this tenant (only those that are active AND SSO-enabled) query = select(IdentityProvider).where( - IdentityProvider.is_active == True, - IdentityProvider.sso_login_enabled == True, + IdentityProvider.is_active, + IdentityProvider.sso_login_enabled, ) if session.tenant_id: query = query.where(IdentityProvider.tenant_id == session.tenant_id) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 5a3f01ecb..f0ade9f03 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -130,7 +130,7 @@ async def get_task_logs( result = await db.execute( select(TaskLog).where(TaskLog.task_id == task_id).order_by(TaskLog.created_at.asc()) ) - return [TaskLogOut.model_validate(l) for l in result.scalars().all()] + return [TaskLogOut.model_validate(log_entry) for log_entry in result.scalars().all()] @router.post("/{task_id}/logs", response_model=TaskLogOut, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/api/teams.py b/backend/app/api/teams.py index 09368ebe1..77091c9bd 100644 --- a/backend/app/api/teams.py +++ b/backend/app/api/teams.py @@ -1,7 +1,5 @@ """Microsoft Teams Bot Channel API routes.""" -import hashlib -import hmac import json import os import time @@ -9,7 +7,7 @@ from datetime import datetime, timezone import httpx -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -26,10 +24,7 @@ from app.services.channel_session import find_or_create_channel_session from app.api.feishu import _call_agent_llm from app.services.agent_tools import channel_file_sender as _cfs_s -from app.core.security import hash_password as _hp from pathlib import Path as _Path -import asyncio as _asyncio -import random as _random settings = get_settings() @@ -77,7 +72,7 @@ async def _get_teams_access_token(config: ChannelConfig) -> str | None: await credential.close() return token.token except ImportError: - logger.error(f"Teams: azure-identity package not installed. Install it with: pip install azure-identity") + logger.error("Teams: azure-identity package not installed. Install it with: pip install azure-identity") return None except Exception as e: logger.exception(f"Teams: Failed to get access token via managed identity for agent {agent_id}: {e}") @@ -109,7 +104,7 @@ async def _get_teams_access_token(config: ChannelConfig) -> str | None: error_description = error_json.get("error_description", "No description") error_code = error_json.get("error", "unknown") logger.error(f"Teams: OAuth token request failed for agent {agent_id}: status={resp.status_code}, error={error_code}, description={error_description}") - except: + except Exception: logger.error(f"Teams: OAuth token request failed for agent {agent_id}: status={resp.status_code}, response={error_body[:500]}") logger.error(f"Teams: Token URL={token_url}, tenant_id={tenant_id}, client_id={app_id[:20]}...") return None @@ -131,7 +126,7 @@ async def _get_teams_access_token(config: ChannelConfig) -> str | None: error_description = error_json.get("error_description", "No description") error_code = error_json.get("error", "unknown") logger.error(f"Teams: OAuth token HTTP error for agent {agent_id}: status={e.response.status_code}, error={error_code}, description={error_description}") - except: + except Exception: logger.error(f"Teams: OAuth token HTTP error for agent {agent_id}: status={e.response.status_code if hasattr(e, 'response') and e.response else 'unknown'}, response={error_body[:500]}") logger.error(f"Teams: Token URL={token_url}, tenant_id={tenant_id}, client_id={app_id[:20]}...") return None @@ -195,7 +190,7 @@ async def _send_teams_message_single_chunk(access_token: str, service_url: str, error_description = error_json.get("error", {}).get("message", error_json.get("message", "No description")) error_code = error_json.get("error", {}).get("code", "unknown") logger.error(f"Teams: Failed to send message: status={resp.status_code}, error={error_code}, description={error_description}") - except: + except Exception: logger.error(f"Teams: Failed to send message: status={resp.status_code}, response={error_body[:500]}") logger.error(f"Teams: POST URL={post_url}, conversation_id={conversation_id}, service_url={service_url}") resp.raise_for_status() @@ -208,7 +203,7 @@ async def _send_teams_message_single_chunk(access_token: str, service_url: str, error_description = error_json.get("error", {}).get("message", error_json.get("message", "No description")) error_code = error_json.get("error", {}).get("code", "unknown") logger.error(f"Teams: HTTP error sending message: status={e.response.status_code}, error={error_code}, description={error_description}") - except: + except Exception: logger.error(f"Teams: HTTP error sending message: status={e.response.status_code if hasattr(e, 'response') and e.response else 'unknown'}, response={error_body[:500]}") logger.error(f"Teams: POST URL={post_url}, conversation_id={conversation_id}, service_url={service_url}") raise @@ -535,7 +530,7 @@ async def _teams_file_sender(file_path, msg: str = ""): if config.app_id: bot_channel_account = {"id": config.app_id} else: - logger.error(f"Teams: Cannot determine bot channel account ID - no recipient in activity and no app_id configured") + logger.error("Teams: Cannot determine bot channel account ID - no recipient in activity and no app_id configured") raise ValueError("Cannot determine bot channel account ID") # Get the user (sender) from the incoming activity's from field @@ -553,7 +548,7 @@ async def _teams_file_sender(file_path, msg: str = ""): } logger.info(f"Teams: Attempting to send reply to conversation {conversation_id}, from={bot_channel_account.get('id')}, recipient={user_account.get('id')}") await _send_teams_message(config, conversation_id, reply_activity) - logger.info(f"Teams: Successfully sent reply to Teams") + logger.info("Teams: Successfully sent reply to Teams") except Exception as e: logger.exception(f"Teams: Failed to send message to Teams: {e}") else: diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index 002e0336a..0aea39520 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -274,7 +274,7 @@ async def join_company( ic_result = await db.execute( select(InvitationCode).where( InvitationCode.code == data.invitation_code, - InvitationCode.is_active == True, + InvitationCode.is_active, InvitationCode.tenant_id.is_not(None), ) ) diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index bb1cd545d..aa4deedbe 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -2,7 +2,7 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import String, cast, select, delete, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -12,12 +12,9 @@ from app.models.tool import Tool, AgentTool from app.models.user import User from app.services.tool_config import ( - SENSITIVE_FIELD_KEYS, - delete_tenant_tool_config, decrypt_sensitive_fields, encrypt_sensitive_fields, get_sensitive_keys, - get_tenant_tool_config, get_tool_company_config, mask_sensitive_fields, meaningful_config, @@ -161,7 +158,7 @@ async def list_tools( target_tenant_id = _resolve_target_tenant_id(current_user, tenant_id) if target_tenant_id: from sqlalchemy import or_ as _or - query = query.where(_or(Tool.tenant_id == None, Tool.tenant_id == target_tenant_id)) + query = query.where(_or(Tool.tenant_id is None, Tool.tenant_id == target_tenant_id)) result = await db.execute(query) tools = result.scalars().all() response = [] @@ -333,7 +330,7 @@ async def get_agent_tools( # All tools visible within this agent's tenant boundary all_tools_r = await db.execute( select(Tool) - .where(Tool.enabled == True, _agent_visible_tool_clause(agent_obj.tenant_id, assignments)) + .where(Tool.enabled, _agent_visible_tool_clause(agent_obj.tenant_id, assignments)) .order_by(Tool.category, Tool.name) ) all_tools = all_tools_r.scalars().all() @@ -686,7 +683,7 @@ async def get_agent_tools_with_config( assignments = await _load_agent_tool_assignments(db, agent_id) all_tools_r = await db.execute( select(Tool) - .where(Tool.enabled == True, _agent_visible_tool_clause(agent_obj2.tenant_id, assignments)) + .where(Tool.enabled, _agent_visible_tool_clause(agent_obj2.tenant_id, assignments)) .order_by(Tool.category, Tool.name) ) all_tools = all_tools_r.scalars().all() @@ -828,7 +825,7 @@ async def get_category_config( all_cat_tools = await db.execute( select(Tool).where( Tool.category == category, - Tool.enabled == True, + Tool.enabled, _agent_visible_tool_clause(agent.tenant_id, await _load_agent_tool_assignments(db, agent_id)), ).order_by((Tool.name != primary_tool_name) if primary_tool_name else Tool.name, Tool.name) ) diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 1aa0ea166..ef81dc00e 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -6,7 +6,6 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Form -from loguru import logger from app.core.security import get_current_user from app.models.user import User from app.config import get_settings diff --git a/backend/app/api/users.py b/backend/app/api/users.py index ecb85eca3..d89337f40 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -73,7 +73,7 @@ async def list_users( count_result = await db.execute( select(func.count()).select_from(Agent).where( Agent.creator_id == u.id, - Agent.is_expired == False, + not Agent.is_expired, ) ) agents_count = count_result.scalar() or 0 @@ -139,7 +139,7 @@ async def update_user_quota( count_result = await db.execute( select(func.count()).select_from(Agent).where( Agent.creator_id == user.id, - Agent.is_expired == False, + not Agent.is_expired, ) ) agents_count = count_result.scalar() or 0 diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py index 50dd1f435..89e8849ba 100644 --- a/backend/app/api/webhooks.py +++ b/backend/app/api/webhooks.py @@ -60,7 +60,7 @@ async def receive_webhook(token: str, request: Request): result = await db.execute( select(AgentTrigger).where( AgentTrigger.type == "webhook", - AgentTrigger.is_enabled == True, + AgentTrigger.is_enabled, ) ) triggers = result.scalars().all() diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 0b9e37ff9..943833fd2 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -1,28 +1,232 @@ """WebSocket chat endpoint for real-time agent conversations.""" import json +import re import uuid from datetime import datetime, timezone as tz -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from app.core.security import decode_access_token from app.core.permissions import check_agent_access, is_agent_expired -from app.database import async_session +from app.core.security import get_current_user +from app.database import async_session, get_db from app.models.agent import Agent from app.models.audit import ChatMessage from app.models.chat_session import ChatSession from app.models.llm import LLMModel from app.models.user import User from app.services.chat_session_service import ensure_primary_platform_session -from app.services.llm import call_llm, call_llm_with_failover +from app.services.llm import call_llm_with_failover router = APIRouter(tags=["websocket"]) +_SKILL_RE = re.compile(r"^/([a-z0-9_-]+(?::[a-z0-9_-]+)*)") + + +async def _resolve_skill_content( + skill_key: str, + agent_id: uuid.UUID, +) -> tuple[str | None, str | None, str | None]: + """Resolve skill content by colon key lookup.""" + import asyncio + + from app.services.agent_context import _agent_workspace + from app.services.skill_map import get_skill_map + + skill_map = get_skill_map(agent_id) + entry = skill_map.get(skill_key) + if not entry: + return None, None, None + + file_rel = entry.get("file") + if not file_rel: + return None, None, None + + ws_root = _agent_workspace(agent_id) + file_path = (ws_root / "skills" / file_rel).resolve() + skills_root = (ws_root / "skills").resolve() + if not str(file_path).startswith(str(skills_root)): + return None, None, None + if not file_path.exists(): + return None, None, None + + content = await asyncio.to_thread( + file_path.read_text, + encoding="utf-8", + errors="replace", + ) + return content, entry.get("name", skill_key), entry.get("emoji", "") + + +async def _persist_and_inject_skill( + skill_content: str, + agent_id: uuid.UUID, + user_id: uuid.UUID, + conv_id: str, + db: AsyncSession, + conversation: list[dict], + websocket: WebSocket, +) -> None: + """Persist a hidden user-context message and append it to the LLM conversation.""" + hidden_msg = ChatMessage( + agent_id=agent_id, + user_id=user_id, + role="user", + content=skill_content, + conversation_id=conv_id, + is_hidden=True, + ) + db.add(hidden_msg) + await db.commit() + conversation.append({"role": "user", "content": skill_content}) + if not hasattr(websocket, "_hidden_indices"): + websocket._hidden_indices = set() + websocket._hidden_indices.add(len(conversation) - 1) + + +async def _inject_skill_if_matched( + content: str, + agent_id: uuid.UUID, + user_id: uuid.UUID, + conv_id: str, + db: AsyncSession, + conversation: list[dict], + websocket: WebSocket, +) -> None: + """Parse skill prefix from content and inject hidden context if found.""" + match = _SKILL_RE.match(content) + if not match: + return + + skill_key = match.group(1) + skill_content, display_name, emoji = await _resolve_skill_content(skill_key, agent_id) + if skill_content: + await _persist_and_inject_skill(skill_content, agent_id, user_id, conv_id, db, conversation, websocket) + await websocket.send_json( + { + "type": "skill_loaded", + "name": display_name, + "emoji": emoji or "", + "content": skill_content, + } + ) + elif ":" in skill_key: + await websocket.send_json({"type": "skill_error", "message": f"Skill '{skill_key}' not found"}) + + +def _find_retry_anchor_message(messages: list, requested_message_id: str | None = None): + """Find the visible user message a retry/regenerate request should replay. + + Rules: + - If `requested_message_id` points at a user message, retry that turn. + - If it points at an assistant/tool message, retry the nearest preceding + visible user message. + - If omitted, retry the most recent visible user message in the thread. + Hidden skill-injection messages are never used as retry anchors. + """ + if not messages: + return None + + target_index = len(messages) - 1 + if requested_message_id: + requested = str(requested_message_id) + for idx, msg in enumerate(messages): + msg_id = getattr(msg, "id", None) + if msg_id and str(msg_id) == requested: + target_index = idx + break + else: + return None + + for idx in range(target_index, -1, -1): + msg = messages[idx] + if getattr(msg, "role", None) == "user" and not getattr(msg, "is_hidden", False): + return msg + return None + + +def _rebuild_conversation_from_messages( + messages: list, + conversation: list[dict], + websocket: WebSocket, +) -> None: + """Rebuild the in-memory LLM conversation from persisted chat messages.""" + conversation.clear() + websocket._hidden_indices = set() + for rmsg in messages: + if rmsg.role == "tool_call": + try: + tc_data = json.loads(rmsg.content) + tc_id = f"call_{rmsg.id}" + conversation.append( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tc_id, + "type": "function", + "function": { + "name": tc_data.get("name", "unknown"), + "arguments": json.dumps(tc_data.get("args", {}), ensure_ascii=False), + }, + } + ], + } + ) + conversation.append( + { + "role": "tool", + "tool_call_id": tc_id, + "content": str(tc_data.get("result", ""))[:500], + } + ) + except Exception: + continue + else: + conversation.append({"role": rmsg.role, "content": rmsg.content}) + if getattr(rmsg, "is_hidden", False): + websocket._hidden_indices.add(len(conversation) - 1) + + +async def _finalize_onboarding_progress_if_needed( + *, + needs_onboarding_mark: bool, + onboarding_mark_done: bool, + agent_id: uuid.UUID, + user_id: uuid.UUID, + onboarding_target_phase: str, + websocket: WebSocket, +) -> bool: + """Persist onboarding progress when a turn finishes without streaming chunks. + + Greeting turns can complete via a direct `finish` tool call, which returns a + final response without ever invoking the chunk or tool-status callbacks that + normally persist onboarding progress. In that case we still need to advance + the onboarding phase so subsequent turns receive the full tool list. + """ + if not needs_onboarding_mark or onboarding_mark_done: + return onboarding_mark_done + + from app.services.onboarding import mark_onboarding_phase + + async with async_session() as _ob_db: + await mark_onboarding_phase( + _ob_db, + agent_id, + user_id, + onboarding_target_phase, + ) + await websocket.send_json({ + "type": "onboarded", + "agent_id": str(agent_id), + }) + return True + class ConnectionManager: """Manage WebSocket connections per agent.""" @@ -90,6 +294,34 @@ def is_user_viewing_session(self, agent_id: str, session_id: str, user_id: str) manager = ConnectionManager() +def _should_generate_session_title( + needs_session_title_generation: bool, + skip_user_save: bool, + is_onboarding_trigger: bool, +) -> bool: + """Only title the first persisted non-onboarding user exchange.""" + return needs_session_title_generation and not skip_user_save and not is_onboarding_trigger + + +def _rewrite_edited_user_content(original_content: str, replacement_text: str) -> str: + """Preserve attachment/banner wrapper lines when editing a user message body.""" + preserved_prefix: list[str] = [] + for line in original_content.splitlines(): + stripped = line.strip() + if ( + stripped.startswith("[file:") + or stripped.startswith("[image_data:") + or stripped.startswith("[Attachment:") + ): + preserved_prefix.append(line) + continue + break + + if preserved_prefix: + return "\n".join([*preserved_prefix, replacement_text]) + return replacement_text + + async def maybe_mark_session_read_for_active_viewer( db: AsyncSession, *, @@ -109,12 +341,6 @@ async def maybe_mark_session_read_for_active_viewer( return True -from fastapi import Depends -from app.core.security import get_current_user -from app.database import get_db -from app.models.user import User - - @router.get("/api/chat/{agent_id}/history") async def get_chat_history( agent_id: uuid.UUID, @@ -132,7 +358,14 @@ async def get_chat_history( messages = result.scalars().all() out = [] for m in messages: - entry: dict = {"role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None} + entry: dict = { + "id": str(m.id), + "role": m.role, + "content": m.content, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + if getattr(m, "is_hidden", False): + entry["is_hidden"] = True if getattr(m, 'thinking', None): entry["thinking"] = m.thinking if m.role == "tool_call": @@ -191,6 +424,7 @@ async def websocket_chat( llm_model = None fallback_llm_model = None history_messages = [] + needs_session_title_generation = False try: async with async_session() as db: @@ -203,6 +437,7 @@ async def websocket_chat( await websocket.close(code=4001) return + _tenant_id = user.tenant_id # Capture for title generation logger.info(f"[WS] Checking agent access for {agent_id}") agent, _ = await check_agent_access(db, user, agent_id) # Check agent expiry @@ -210,6 +445,7 @@ async def websocket_chat( await websocket.send_json({"type": "error", "content": "This Agent has expired and is off duty. Please contact your admin to extend its service."}) await websocket.close(code=4003) return + _tenant_id = user.tenant_id # Capture for title generation agent_name = agent.name agent_type = agent.agent_type or "" role_description = agent.role_description or "" @@ -254,7 +490,6 @@ async def websocket_chat( # Resolve or create chat session from app.models.chat_session import ChatSession from sqlalchemy import select as _sel - from datetime import datetime as _dt, timezone as _tz conv_id = session_id if conv_id: # Validate the session belongs to this agent and to this user. @@ -287,8 +522,8 @@ async def websocket_chat( ChatSession.agent_id == agent_id, ChatSession.user_id == user_id, ChatSession.source_channel == "web", - ChatSession.is_group == False, - ChatSession.is_primary == True, + ChatSession.is_group.is_(False), + ChatSession.is_primary.is_(True), ) .order_by(ChatSession.last_message_at.desc().nulls_last(), ChatSession.created_at.desc()) .limit(1) @@ -313,6 +548,7 @@ async def websocket_chat( ) history_messages = list(reversed(history_result.scalars().all())) logger.info(f"[WS] Loaded {len(history_messages)} history messages for session {conv_id}") + needs_session_title_generation = not history_messages except Exception as e: logger.warning(f"[WS] History load failed (non-fatal): {e}") except Exception as e: @@ -334,6 +570,8 @@ async def websocket_chat( # Build conversation context from history conversation: list[dict] = [] + if not hasattr(websocket, "_hidden_indices"): + websocket._hidden_indices = set() for msg in history_messages: if msg.role == "tool_call": # Convert stored tool_call JSON into OpenAI-format assistant+tool pair @@ -372,6 +610,8 @@ async def websocket_chat( if hasattr(msg, 'thinking') and msg.thinking: entry["thinking"] = msg.thinking conversation.append(entry) + if getattr(msg, "is_hidden", False): + websocket._hidden_indices.add(len(conversation) - 1) try: # Send welcome message on new session (no history) @@ -381,6 +621,7 @@ async def websocket_chat( while True: logger.info(f"[WS] Waiting for message from {agent_name}...") data = await websocket.receive_json() + msg_type = data.get("type", "message") # Set a unique trace ID for this specific message processing. from app.core.logging_config import set_trace_id @@ -388,6 +629,7 @@ async def websocket_chat( trace_id = str(_trace_uuid.uuid4())[:12] set_trace_id(trace_id) + skip_user_save = False content = data.get("content", "") display_content = data.get("display_content", "") # User-facing display text file_name = data.get("file_name", "") # Original file name for attachment display @@ -399,6 +641,123 @@ async def websocket_chat( is_onboarding_trigger = data.get("kind") == "onboarding_trigger" logger.info(f"[WS] Received: {content[:50]}" + (" [onboarding]" if is_onboarding_trigger else "")) + if msg_type == "edit": + edit_message_id = data.get("message_id") + edit_content = data.get("content", "") + if not edit_message_id or not edit_content: + continue + + from sqlalchemy import select as sa_select + + async with async_session() as edit_db: + edit_result = await edit_db.execute( + sa_select(ChatMessage).where( + ChatMessage.id == edit_message_id, + ChatMessage.conversation_id == conv_id, + ChatMessage.agent_id == agent_id, + ChatMessage.role == "user", + ) + ) + edit_msg = edit_result.scalar_one_or_none() + if not edit_msg: + await websocket.send_json({"type": "skill_error", "message": "Message not found"}) + continue + + await edit_db.execute( + ChatMessage.__table__.delete().where( + ChatMessage.conversation_id == conv_id, + ChatMessage.created_at >= edit_msg.created_at, + ChatMessage.id != edit_msg.id, + ) + ) + edit_msg.content = edit_content + await edit_db.commit() + + rebuild_result = await edit_db.execute( + sa_select(ChatMessage) + .where(ChatMessage.agent_id == agent_id, ChatMessage.conversation_id == conv_id) + .order_by(ChatMessage.created_at.asc()) + ) + rebuild_msgs = rebuild_result.scalars().all() + + _rebuild_conversation_from_messages(rebuild_msgs, conversation, websocket) + + await websocket.send_json({"type": "edit_ack", "message_id": str(edit_msg.id)}) + content = edit_content + display_content = "" + file_name = "" + skip_user_save = True + + elif msg_type in {"retry", "regenerate"}: + retry_message_id = data.get("message_id") + from sqlalchemy import select as sa_select + + async with async_session() as retry_db: + history_result = await retry_db.execute( + sa_select(ChatMessage) + .where(ChatMessage.agent_id == agent_id, ChatMessage.conversation_id == conv_id) + .order_by(ChatMessage.created_at.asc()) + ) + persisted_messages = history_result.scalars().all() + retry_anchor = _find_retry_anchor_message(persisted_messages, retry_message_id) + if not retry_anchor: + await websocket.send_json( + { + "type": "skill_error", + "message": "No visible user message found to retry", + } + ) + continue + + await retry_db.execute( + ChatMessage.__table__.delete().where( + ChatMessage.conversation_id == conv_id, + ChatMessage.created_at > retry_anchor.created_at, + ) + ) + await retry_db.commit() + + rebuild_result = await retry_db.execute( + sa_select(ChatMessage) + .where(ChatMessage.agent_id == agent_id, ChatMessage.conversation_id == conv_id) + .order_by(ChatMessage.created_at.asc()) + ) + rebuild_msgs = rebuild_result.scalars().all() + + _rebuild_conversation_from_messages(rebuild_msgs, conversation, websocket) + + await websocket.send_json({"type": "retry_ack", "message_id": str(retry_anchor.id)}) + content = retry_anchor.content or "" + display_content = "" + file_name = "" + skip_user_save = True + + elif content.strip() == "/role:clear": + async with async_session() as clear_db: + await clear_db.execute( + ChatMessage.__table__.delete().where( + ChatMessage.conversation_id == conv_id, + ChatMessage.is_hidden.is_(True), + ) + ) + await clear_db.commit() + hidden = getattr(websocket, "_hidden_indices", set()) + conversation[:] = [m for i, m in enumerate(conversation) if i not in hidden] + websocket._hidden_indices = set() + await websocket.send_json({"type": "skill_loaded", "name": "Roles cleared", "emoji": ""}) + continue + else: + async with async_session() as skill_db: + await _inject_skill_if_matched( + content, + agent_id, + user_id, + conv_id, + skill_db, + conversation, + websocket, + ) + if not content and not is_onboarding_trigger: continue if is_onboarding_trigger: @@ -448,7 +807,7 @@ async def websocket_chat( try: from app.services.quota_guard import ( check_conversation_quota, increment_conversation_usage, - check_agent_expired, check_agent_llm_quota, increment_agent_llm_usage, + check_agent_expired, increment_agent_llm_usage, QuotaExceeded, AgentExpired, ) await check_conversation_quota(user_id) @@ -460,8 +819,9 @@ async def websocket_chat( await websocket.send_json({"type": "done", "role": "assistant", "content": f"⚠️ {ae.message}"}) continue - # Add user message to conversation (full LLM context) - conversation.append({"role": "user", "content": content}) + if not skip_user_save: + # Add user message to conversation (full LLM context) + conversation.append({"role": "user", "content": content}) # Save user message to DB. # @@ -478,7 +838,10 @@ async def websocket_chat( saved_content = display_content if display_content else content if file_name: saved_content = f"[file:{file_name}]\n{saved_content}" - if is_onboarding_trigger: + user_msg_id: str | None = None + if skip_user_save: + logger.info("[WS] Edit replay — skipping new user-message persistence") + elif is_onboarding_trigger: logger.info("[WS] Onboarding trigger — skipping user-message persistence") # Title this session "Onboarding" up front so it's identifiable # in the session list even before the user has typed anything. @@ -522,7 +885,9 @@ async def websocket_chat( clean_title = f"📎 {file_name}" _sess.title = clean_title[:40] if clean_title else content[:40] await db.commit() + user_msg_id = str(user_msg.id) logger.info("[WS] User message saved") + await websocket.send_json({"type": "user_saved", "message_id": user_msg_id}) # ── OpenClaw routing: insert into gateway_messages instead of LLM ── if agent_type == "openclaw": @@ -837,7 +1202,7 @@ async def _on_failover(reason: str): websocket.receive_json(), timeout=0.5 ) if msg.get("type") == "abort": - logger.info(f"[WS] Abort received, cancelling LLM task") + logger.info("[WS] Abort received, cancelling LLM task") llm_task.cancel() aborted = True break @@ -874,9 +1239,20 @@ async def _on_failover(reason: str): ): raise RuntimeError(assistant_response) - # Update last_active_at. The onboarding lock is handled - # earlier in stream_to_ws on the first streamed chunk, so - # there's nothing to reconcile here anymore. + # Update onboarding progress for finish-only turns that never emitted + # a streamed chunk or tool-status callback (for example an + # onboarding greeting that immediately returns via `finish`). + if not aborted: + onboarding_mark_done = await _finalize_onboarding_progress_if_needed( + needs_onboarding_mark=needs_onboarding_mark, + onboarding_mark_done=onboarding_mark_done, + agent_id=agent_id, + user_id=user_id, + onboarding_target_phase=onboarding_target_phase, + websocket=websocket, + ) + + # Update last_active_at after onboarding reconciliation. from datetime import datetime, timezone as tz async with async_session() as _db: from app.models.agent import Agent as AgentModel @@ -954,10 +1330,88 @@ async def _on_failover(reason: str): user_id=user_id, ) await db.commit() + assistant_msg_id = str(assistant_msg.id) logger.info("[WS] Assistant message saved") + if not history_messages and not is_onboarding_trigger: + try: + tenant_id = getattr(effective_llm_model, "tenant_id", None) or getattr(llm_model, "tenant_id", None) + if tenant_id: + from app.models.tenant import Tenant as _Tenant + from app.models.llm import LLMModel as _UtilityLLM + from app.services.session_title import generate_session_title + import asyncio as _asyncio_title + + async with async_session() as title_db: + tenant_result = await title_db.execute(select(_Tenant).where(_Tenant.id == tenant_id)) + tenant = tenant_result.scalar_one_or_none() + utility_model = None + if tenant and tenant.utility_model_id: + utility_result = await title_db.execute( + select(_UtilityLLM).where(_UtilityLLM.id == tenant.utility_model_id) + ) + utility_model = utility_result.scalar_one_or_none() + + if utility_model: + _asyncio_title.create_task( + generate_session_title( + conv_id, + display_content if display_content else content, + assistant_response, + utility_model, + websocket, + ) + ) + except Exception as title_err: + logger.warning(f"[WS] Session title scheduling failed: {title_err}") + # Final 'done' packet - await websocket.send_json({"type": "done", "role": "assistant", "content": assistant_response}) + await websocket.send_json( + { + "type": "done", + "role": "assistant", + "content": assistant_response, + "message_id": assistant_msg_id, + } + ) + + # Re-process any queued messages (if user sent something during generation) + for qm in queued_messages: + # In a real implementation, you might want to push these back to the main loop + pass + + # ── Auto-generate session title on first real exchange only ── + if _should_generate_session_title(needs_session_title_generation, skip_user_save, is_onboarding_trigger) and _tenant_id: + try: + from app.models.tenant import Tenant as _Tenant + from app.models.llm import LLMModel as _LLM + import asyncio as _asyncio + async with async_session() as _tdb: + _t_r = await _tdb.execute( + select(_Tenant).where(_Tenant.id == _tenant_id) + ) + _tenant = _t_r.scalar_one_or_none() + if _tenant and _tenant.utility_model_id: + _m_r = await _tdb.execute( + select(_LLM).where(_LLM.id == _tenant.utility_model_id) + ) + _util_model = _m_r.scalar_one_or_none() + if _util_model: + from app.services.session_title import generate_session_title + _first_user_msg = display_content if display_content else content + _asyncio.create_task( + generate_session_title( + session_id=conv_id, + user_message=_first_user_msg, + assistant_response=assistant_response[:500], + utility_model=_util_model, + websocket=websocket, + ) + ) + needs_session_title_generation = False + except Exception as _e: + logger.warning(f"[WS] Title generation trigger failed: {_e}") + needs_session_title_generation = False # Re-process any queued messages (if user sent something during generation) for qm in queued_messages: diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index a12e8f534..b7b4a6179 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -131,7 +131,7 @@ async def serve_wecom_verify_file( result = await db.execute( select(IdentityProvider).where( IdentityProvider.provider_type == "wecom", - IdentityProvider.is_active == True, + IdentityProvider.is_active, ) ) providers = result.scalars().all() @@ -260,9 +260,11 @@ async def get_wecom_channel( if not config: raise HTTPException(status_code=404, detail="WeCom not configured") + from app.services.wecom_stream import wecom_stream_manager as runtime_wecom_stream_manager + config_out = ChannelConfigOut.model_validate(config) if (config.extra_config or {}).get("connection_mode") == "websocket": - config_out.is_connected = wecom_stream_manager.status().get(str(agent_id), False) + config_out.is_connected = runtime_wecom_stream_manager.status().get(str(agent_id), False) else: config_out.is_connected = False return config_out @@ -296,7 +298,9 @@ async def delete_wecom_channel( config = result.scalar_one_or_none() if not config: raise HTTPException(status_code=404, detail="WeCom not configured") - await wecom_stream_manager.stop_client(agent_id) + from app.services.wecom_stream import wecom_stream_manager as runtime_wecom_stream_manager + + await runtime_wecom_stream_manager.stop_client(agent_id) await db.delete(config) @@ -681,8 +685,8 @@ async def wecom_callback( raise HTTPException(status_code=404, detail="WeCom provider not configured for this tenant") config = provider.config - corp_id = config.get("app_id") or config.get("corp_id") - secret = config.get("app_secret") or config.get("secret") + config.get("app_id") or config.get("corp_id") + config.get("app_secret") or config.get("secret") # 2. Extract user info and login/register via RegistrationService try: diff --git a/backend/app/api/workspace.py b/backend/app/api/workspace.py new file mode 100644 index 000000000..84584fc68 --- /dev/null +++ b/backend/app/api/workspace.py @@ -0,0 +1,157 @@ +"""Public workspace API routes (bug reports, project listing).""" + +import logging +import time +from collections import defaultdict + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import get_current_admin +from app.database import get_db +from app.models.user import User +from app.models.workspace import WorkspaceBugReport, WorkspaceProject +from app.services.workspace_tools import approve_container_deploy, reject_container_deploy + +logger = logging.getLogger(__name__) + +# Public router — no auth required, registered without API prefix +public_router = APIRouter(tags=["workspace-public"]) + +# Authenticated router — under /api/workspace +router = APIRouter(prefix="/workspace", tags=["workspace"]) + +# Simple in-memory rate limiter: {ip: [timestamps]} +_rate_limit: dict[str, list[float]] = defaultdict(list) +RATE_LIMIT_MAX = 5 +RATE_LIMIT_WINDOW = 3600 # 1 hour + + +class BugReportRequest(BaseModel): + description: str + website: str = "" # honeypot field + + +def _check_rate_limit(ip: str) -> bool: + """Return True if request is within rate limit.""" + now = time.time() + _rate_limit[ip] = [t for t in _rate_limit[ip] if now - t < RATE_LIMIT_WINDOW] + if len(_rate_limit[ip]) >= RATE_LIMIT_MAX: + return False + _rate_limit[ip].append(now) + return True + + +@public_router.post("/api/workspace/projects/{slug}/report-bug") +async def report_bug( + slug: str, + body: BugReportRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Public endpoint for reporting bugs on workspace projects.""" + # Honeypot check + if body.website: + # Bot detected — return 200 silently to not reveal the trap + return {"status": "ok"} + + # Rate limit + client_ip = request.client.host if request.client else "unknown" + if not _check_rate_limit(client_ip): + raise HTTPException(status_code=429, detail="Too many reports. Try again later.") + + # Find project + result = await db.execute( + select(WorkspaceProject).where( + WorkspaceProject.slug == slug, + WorkspaceProject.status == "deployed", + ) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + # Create bug report + report = WorkspaceBugReport( + project_id=project.id, + source="user_report", + description=body.description[:2000], # cap length + ) + db.add(report) + await db.commit() + + logger.info("Bug report created for project '%s' from %s", slug, client_ip) + return {"status": "ok", "message": "Report submitted. Thank you!"} + + +class ApproveRequest(BaseModel): + memory: str = "" + cpus: str = "" + + +def _workspace_scope_args(current_user: User) -> dict: + """Resolve tenant scoping for workspace admin actions.""" + is_platform_admin = current_user.role == "platform_admin" or bool( + getattr(getattr(current_user, "identity", None), "is_platform_admin", False) + ) + if is_platform_admin: + return {"tenant_id": None, "include_platform_global": True} + return {"tenant_id": current_user.tenant_id, "include_platform_global": False} + + +@router.post("/projects/{slug}/approve") +async def approve_deploy( + slug: str, + body: ApproveRequest | None = None, + current_user: User = Depends(get_current_admin), +): + """Approve a container deployment (admin only, under /api/workspace).""" + limits = {} + if body: + if body.memory: + limits["memory"] = body.memory + if body.cpus: + limits["cpus"] = body.cpus + result = await approve_container_deploy(slug, limits if limits else None, **_workspace_scope_args(current_user)) + if not result["ok"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"]) + return result + + +@router.post("/projects/{slug}/reject") +async def reject_deploy( + slug: str, + current_user: User = Depends(get_current_admin), +): + """Reject a container deployment (admin only, under /api/workspace).""" + result = await reject_container_deploy(slug, **_workspace_scope_args(current_user)) + if not result["ok"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"]) + return result + + +@router.get("/projects") +async def list_projects( + current_user: User = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """List all workspace projects (admin only).""" + scope = _workspace_scope_args(current_user) + query = select(WorkspaceProject) + if scope["tenant_id"] is not None: + query = query.where(WorkspaceProject.tenant_id == scope["tenant_id"]) + result = await db.execute(query.order_by(WorkspaceProject.created_at.desc())) + projects = result.scalars().all() + return [ + { + "slug": p.slug, + "name": p.name, + "status": p.status, + "deploy_type": p.deploy_type, + "container_port": p.container_port, + "url": f"/workspace/{p.slug}/" if p.status == "deployed" else None, + } + for p in projects + ] diff --git a/backend/app/config.py b/backend/app/config.py index 9b0ad0e02..19f51d3e5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,16 +46,14 @@ def _default_agent_template_dir() -> str: return str(source_path) -def _default_allow_unsafe_bwrap_fallback() -> bool: - """Allow local source runs to work without bubblewrap by default.""" - return not _running_in_container() - - def _read_version() -> str: """Read version from local VERSION file, fallback to root.""" - for candidate in [Path(__file__).resolve().parent.parent / "VERSION", - Path(__file__).resolve().parent.parent.parent / "VERSION", - Path("/app/VERSION"), Path("/VERSION")]: + for candidate in [ + Path(__file__).resolve().parent.parent / "VERSION", + Path(__file__).resolve().parent.parent.parent / "VERSION", + Path("/app/VERSION"), + Path("/VERSION"), + ]: try: return candidate.read_text(encoding="utf-8").strip() except OSError: @@ -90,6 +88,9 @@ class Settings(BaseSettings): # File Storage AGENT_DATA_DIR: str = _default_agent_data_dir() AGENT_TEMPLATE_DIR: str = _default_agent_template_dir() + WORKSPACE_STATIC_DIR: str = "/srv/workspace" + WORKSPACE_CONF_DIR: str = "/etc/nginx/workspace.d" + WORKSPACE_GATEWAY_CONTAINER: str = "workspace_gateway" # Docker (for Agent containers) DOCKER_NETWORK: str = "clawith_network" @@ -120,7 +121,6 @@ class Settings(BaseSettings): SANDBOX_CPU_LIMIT: str = "0.5" SANDBOX_MEMORY_LIMIT: str = "256m" SANDBOX_ALLOW_NETWORK: bool = False - SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING: bool = _default_allow_unsafe_bwrap_fallback() SANDBOX_DEFAULT_TIMEOUT: int = 30 SANDBOX_MAX_TIMEOUT: int = 60 @@ -149,7 +149,6 @@ def get_sandbox_config() -> SandboxConfig: cpu_limit=settings.SANDBOX_CPU_LIMIT, memory_limit=settings.SANDBOX_MEMORY_LIMIT, allow_network=settings.SANDBOX_ALLOW_NETWORK, - allow_unsafe_fallback_when_bwrap_missing=settings.SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING, default_timeout=settings.SANDBOX_DEFAULT_TIMEOUT, max_timeout=settings.SANDBOX_MAX_TIMEOUT, ) diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 03caa50d6..b7dcd696d 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -4,8 +4,6 @@ import ssl import smtplib from contextlib import contextmanager -from email.mime.multipart import MIMEMultipart -from typing import Optional def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py index c9afab182..b6ec05b62 100644 --- a/backend/app/core/logging_config.py +++ b/backend/app/core/logging_config.py @@ -3,9 +3,8 @@ import sys import logging from contextvars import ContextVar -from typing import Optional -from loguru import logger +from loguru import logger as loguru_logger # Context variable for trace ID from uuid import uuid4 @@ -37,10 +36,10 @@ def set_trace_id(trace_id: str) -> None: def configure_logging(): """Configure loguru with custom format including trace ID.""" # Remove default handler - logger.remove() + loguru_logger.remove() # Add stdout handler with custom format and filter to ensure trace_id exists - logger.add( + loguru_logger.add( sys.stdout, level="INFO", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {extra[trace_id]:-<12} | {name}:{line} - {message}", @@ -50,7 +49,7 @@ def configure_logging(): filter=lambda record: (record["extra"].setdefault("trace_id", get_trace_id() or str(uuid4())) is not None) ) - return logger + return loguru_logger def quiet_noisy_connection_loggers() -> None: @@ -66,7 +65,7 @@ class InterceptHandler(logging.Handler): def emit(self, record): # Get corresponding loguru level try: - level = logger.level(record.levelname).name + level = loguru_logger.level(record.levelname).name except ValueError: level = record.levelno @@ -86,7 +85,7 @@ def emit(self, record): else: message = record.msg - logger.opt(depth=depth, exception=record.exc_info).log( + loguru_logger.opt(depth=depth, exception=record.exc_info).log( level, message ) diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index ada1d4076..6142c92d4 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -6,7 +6,7 @@ from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware -from app.core.logging_config import set_trace_id, get_trace_id +from app.core.logging_config import set_trace_id from loguru import logger diff --git a/backend/app/main.py b/backend/app/main.py index 88e3bd27b..616c70caa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,8 @@ """Clawith Backend — FastAPI Application Entry Point.""" from contextlib import asynccontextmanager +import json +import os from pathlib import Path import shutil @@ -20,9 +22,9 @@ def _log_bwrap_startup_status() -> None: """Emit a startup diagnostic for bubblewrap availability. - We only warn when bwrap is missing so deployments can still start. Local - source runs may explicitly allow a reduced-isolation fallback, while - containerized deployments should keep fail-closed behavior. + We only warn when bwrap is missing so deployments can still start in + degraded mode. The subprocess sandbox will fall back to the hardened local + execution path in that case. """ in_container = Path("/.dockerenv").exists() bwrap_path = shutil.which("bwrap") @@ -35,43 +37,30 @@ def _log_bwrap_startup_status() -> None: if in_container: logger.warning( "[startup] bubblewrap (bwrap) is not installed in the backend container. " - "The service will still start, but execute_code will fail closed unless " - "SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING=true is explicitly set." + "The service will still start, but execute_code will run without bwrap filesystem isolation." ) return - if settings.SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING: - logger.warning( - "[startup] bubblewrap (bwrap) is not installed on the host. " - "Local execute_code will use the reduced-isolation fallback." - ) - else: - logger.warning( - "[startup] bubblewrap (bwrap) is not installed on the host. " - "execute_code will fail closed unless SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING=true is set." - ) + logger.warning( + "[startup] bubblewrap (bwrap) is not installed on the host. " + "The service will still start, but execute_code will run without bwrap filesystem isolation." + ) async def _start_ss_local() -> None: """Start ss-local SOCKS5 proxy for Discord API calls. Tries nodes in priority order.""" - import asyncio, json, os, shutil, tempfile + import asyncio + import shutil + import tempfile if not shutil.which("ss-local"): logger.info("[Proxy] ss-local not found — Discord proxy disabled") return # Load proxy nodes from config file (gitignored, mounted as Docker volume) - import json as _json cfg_file = os.environ.get("SS_CONFIG_FILE", "/data/ss-nodes.json") - if os.path.exists(cfg_file): - # Guard against empty or malformed config file — both produce a clear - # warning and a clean exit rather than an unhandled JSONDecodeError. - try: - raw = open(cfg_file).read().strip() - if not raw: - logger.warning(f"[Proxy] {cfg_file} exists but is empty — skipping proxy") - return - nodes = _json.loads(raw) - except (json.JSONDecodeError, ValueError) as exc: - logger.warning(f"[Proxy] Failed to parse {cfg_file}: {exc} — skipping proxy") + cfg_path = Path(cfg_file) + if cfg_path.exists(): + nodes = _load_ss_nodes_from_config(cfg_file) + if nodes is None: return logger.info(f"[Proxy] Loaded {len(nodes)} node(s) from {cfg_file}") elif os.environ.get("SS_SERVER") and os.environ.get("SS_PASSWORD"): @@ -84,7 +73,8 @@ async def _start_ss_local() -> None: cfg = {"server": node["server"], "server_port": node["port"], "local_address": "127.0.0.1", "local_port": 1080, "password": node["password"], "method": node["method"], "timeout": 10} tf = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) - json.dump(cfg, tf); tf.close() + json.dump(cfg, tf) + tf.close() try: proc = await asyncio.create_subprocess_exec( "ss-local", "-c", tf.name, @@ -101,6 +91,33 @@ async def _start_ss_local() -> None: logger.warning("[Proxy] All SS nodes failed — Discord API calls will run without proxy") +def _load_ss_nodes_from_config(cfg_file: str) -> list[dict] | None: + """Load SS proxy nodes from JSON config, or return None when unavailable.""" + cfg_path = Path(cfg_file) + if not cfg_path.exists(): + return None + if cfg_path.is_dir(): + logger.warning(f"[Proxy] {cfg_file} is a directory, expected a JSON file — skipping proxy") + return None + if not cfg_path.is_file(): + logger.warning(f"[Proxy] {cfg_file} is not a regular file — skipping proxy") + return None + + # Guard against empty or malformed config file — both produce a clear + # warning and a clean exit rather than an unhandled JSONDecodeError. + try: + raw = cfg_path.read_text().strip() + if not raw: + logger.warning(f"[Proxy] {cfg_file} exists but is empty — skipping proxy") + return None + nodes = json.loads(raw) + except (json.JSONDecodeError, OSError, ValueError) as exc: + logger.warning(f"[Proxy] Failed to parse {cfg_file}: {exc} — skipping proxy") + return None + + return nodes + + @asynccontextmanager async def lifespan(app: FastAPI): """Application startup and shutdown events.""" @@ -118,7 +135,6 @@ async def lifespan(app: FastAPI): ) import asyncio - import sys import os from app.services.trigger_daemon import start_trigger_daemon from app.services.tool_seeder import seed_builtin_tools @@ -128,43 +144,8 @@ async def lifespan(app: FastAPI): from app.services.wecom_stream import wecom_stream_manager from app.services.wechat_channel import wechat_poll_manager from app.services.discord_gateway import discord_gateway_manager + from app.services.workspace_health import run_health_checks - # ── Step 0: Ensure all DB tables exist (idempotent, safe to run on every startup) ── - try: - from app.database import Base, engine - # Import all models so Base.metadata is fully populated - import app.models.user # noqa - import app.models.agent # noqa - import app.models.task # noqa - import app.models.llm # noqa - import app.models.tool # noqa - import app.models.audit # noqa - import app.models.skill # noqa - import app.models.channel_config # noqa - import app.models.schedule # noqa - import app.models.plaza # noqa - import app.models.activity_log # noqa - import app.models.org # noqa - import app.models.system_settings # noqa - import app.models.invitation_code # noqa - import app.models.tenant # noqa - import app.models.tenant_setting # noqa - import app.models.participant # noqa - import app.models.chat_session # noqa - import app.models.trigger # noqa - import app.models.focus # noqa - import app.models.notification # noqa - import app.models.gateway_message # noqa - import app.models.agent_credential # noqa - import app.models.okr # noqa OKR system tables - import app.models.onboarding # noqa - - import app.models.identity # noqa - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - logger.info("[startup] Database tables ready") - except Exception as e: - logger.warning(f"[startup] create_all failed: {e}") # Startup: seed data — each step isolated so one failure doesn't block others logger.info("[startup] seeding...") @@ -280,6 +261,7 @@ def _bg_task_error(t): ("wecom_stream", wecom_stream_manager.start_all()), ("wechat_poll", wechat_poll_manager.start_all()), ("discord_gw", discord_gateway_manager.start_all()), + ("workspace_health", run_health_checks()), ]: task = asyncio.create_task(coro, name=name) task.add_done_callback(_bg_task_error) @@ -360,6 +342,7 @@ def _bg_task_error(t): from app.api.gateway import router as gateway_router from app.api.admin import router as admin_router from app.api.pages import router as pages_router, public_router as pages_public_router +from app.api.workspace import public_router as workspace_public_router, router as workspace_router from app.api.agent_credentials import router as credentials_router from app.api.agentbay_control import router as agentbay_control_router from app.api.okr import router as okr_router @@ -406,6 +389,8 @@ def _bg_task_error(t): app.include_router(admin_router, prefix=settings.API_PREFIX) app.include_router(pages_router, prefix=settings.API_PREFIX) app.include_router(pages_public_router) # Public endpoint for /p/{short_id}, no API prefix +app.include_router(workspace_public_router) # Public workspace endpoints, no API prefix +app.include_router(workspace_router, prefix=settings.API_PREFIX) app.include_router(credentials_router, prefix=settings.API_PREFIX) app.include_router(agentbay_control_router, prefix=settings.API_PREFIX) app.include_router(okr_router) # OKR — self-prefixed at /api/okr @@ -421,7 +406,7 @@ async def health_check(): # ── Version endpoint (public, no auth required) ── def _load_version_info() -> dict[str, str]: """Read version + commit hash once at startup.""" - import os, subprocess + import subprocess version = "unknown" for candidate in ["../frontend/VERSION", "frontend/VERSION", "VERSION"]: try: diff --git a/backend/app/models/activity_log.py b/backend/app/models/activity_log.py index b1eb2fc6f..011472a73 100644 --- a/backend/app/models/activity_log.py +++ b/backend/app/models/activity_log.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func, UniqueConstraint, Integer +from sqlalchemy import DateTime, Enum, ForeignKey, String, func, UniqueConstraint, Integer from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.orm import Mapped, mapped_column diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index 5b3d8c507..dea07f64c 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -3,9 +3,9 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func from sqlalchemy.dialects.postgresql import JSON, UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -61,6 +61,8 @@ class ChatMessage(Base): participant_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("participants.id"), nullable=True) # Model thinking process thinking: Mapped[str | None] = mapped_column(Text, nullable=True) + # Hidden chat messages are stored for model context / skill injection but omitted from normal UI history. + is_hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/backend/app/models/channel_config.py b/backend/app/models/channel_config.py index 77e31f086..bfa20af4e 100644 --- a/backend/app/models/channel_config.py +++ b/backend/app/models/channel_config.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy import DateTime, Enum, ForeignKey, String, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship diff --git a/backend/app/models/chat_session.py b/backend/app/models/chat_session.py index f046d3697..1d6186a9b 100644 --- a/backend/app/models/chat_session.py +++ b/backend/app/models/chat_session.py @@ -53,5 +53,8 @@ class ChatSession(Base): # Tracks when the owning platform user last opened/read this session. Unread badges are derived # from non-user messages created after this timestamp. last_read_at_by_user: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Once a user manually renames a session, auto-title generation should stop overwriting it. + title_edited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + title_edited: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False) diff --git a/backend/app/models/identity.py b/backend/app/models/identity.py index 8122aaf7b..0981b1226 100644 --- a/backend/app/models/identity.py +++ b/backend/app/models/identity.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy import Boolean, DateTime, String, Text, func from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.orm import Mapped, mapped_column diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index dcc24130f..7869430cf 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -4,7 +4,7 @@ from datetime import datetime from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func -from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base diff --git a/backend/app/models/system_settings.py b/backend/app/models/system_settings.py index 82df7317e..b76639fda 100644 --- a/backend/app/models/system_settings.py +++ b/backend/app/models/system_settings.py @@ -1,6 +1,5 @@ """System-level settings (key-value store).""" -import uuid from datetime import datetime from sqlalchemy import DateTime, String, func diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 3ec6f52f5..4cb3d1c08 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -3,8 +3,8 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func -from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index 0865066b1..467a6b069 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -62,6 +62,10 @@ class Tenant(Base): default_model_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("llm_models.id", ondelete="SET NULL"), nullable=True, ) + # Optional utility model used for non-primary helper flows such as session title generation. + utility_model_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("llm_models.id", ondelete="SET NULL"), nullable=True, + ) @property def logo_url(self) -> str | None: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 729930163..c9c223f0b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -3,7 +3,6 @@ import uuid from datetime import datetime -import sqlalchemy as sa from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -104,11 +103,15 @@ class User(Base): identity: Mapped["Identity"] = relationship(back_populates="tenant_users", lazy="selectin") # Association proxies for backward compatibility - email = association_proxy("identity", "email") - username = association_proxy("identity", "username") - password_hash = association_proxy("identity", "password_hash") - email_verified = association_proxy("identity", "email_verified") - primary_mobile = association_proxy("identity", "phone") + email = association_proxy("identity", "email", creator=lambda value: Identity(email=value)) + username = association_proxy("identity", "username", creator=lambda value: Identity(username=value)) + password_hash = association_proxy( + "identity", "password_hash", creator=lambda value: Identity(password_hash=value) + ) + email_verified = association_proxy( + "identity", "email_verified", creator=lambda value: Identity(email_verified=value) + ) + primary_mobile = association_proxy("identity", "phone", creator=lambda value: Identity(phone=value)) created_agents: Mapped[list["Agent"]] = relationship(back_populates="creator", foreign_keys="Agent.creator_id") diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index e2232d1cc..47ab7b9f5 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -1,16 +1,16 @@ -"""Workspace collaboration models. +"""Workspace collaboration and deployment models. -These tables track file revisions and short-lived human editing locks for -agent workspaces. The actual files remain on disk; the database stores the -change history needed for diff viewing and rollback. +These tables track both: +- human collaboration metadata for agent workspaces, and +- public workspace deployment / bug-report state for published projects. """ import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -60,3 +60,90 @@ class WorkspaceEditLock(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + + +class WorkspaceProject(Base): + """A public workspace project requested, built, and optionally deployed.""" + + __tablename__ = "workspace_projects" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True + ) + requested_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True + ) + requested_by_human: Mapped[str | None] = mapped_column(String(200), nullable=True) + built_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True + ) + deploy_type: Mapped[str | None] = mapped_column( + Enum("static", "container", name="deploy_type_enum", create_constraint=False), + nullable=True, + ) + status: Mapped[str] = mapped_column( + Enum( + "requested", + "building", + "awaiting_approval", + "deployed", + "failed", + "rejected", + "stopped", + "undeployed", + name="workspace_status_enum", + create_constraint=False, + ), + nullable=False, + default="requested", + ) + container_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + container_image: Mapped[str | None] = mapped_column(String(300), nullable=True) + container_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + health_endpoint: Mapped[str | None] = mapped_column(String(200), nullable=True) + resource_limits: Mapped[dict | None] = mapped_column(JSON, nullable=True) + dockerfile_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + auto_fix_attempts: Mapped[int] = mapped_column(Integer, default=0) + auto_fix_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + bug_reports: Mapped[list["WorkspaceBugReport"]] = relationship( + back_populates="project", + cascade="all, delete-orphan", + ) + + +class WorkspaceBugReport(Base): + """Bug report for a deployed public workspace project.""" + + __tablename__ = "workspace_bug_reports" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspace_projects.id", ondelete="CASCADE"), + nullable=False, + ) + source: Mapped[str] = mapped_column( + Enum("health_check", "user_report", name="bug_source_enum", create_constraint=False), + nullable=False, + ) + description: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + Enum("open", "investigating", "fixed", "escalated", name="bug_status_enum", create_constraint=False), + nullable=False, + default="open", + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + project: Mapped["WorkspaceProject"] = relationship(back_populates="bug_reports") diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/scripts/bootstrap_db.py b/backend/app/scripts/bootstrap_db.py index 688c6d737..d0ce88c01 100644 --- a/backend/app/scripts/bootstrap_db.py +++ b/backend/app/scripts/bootstrap_db.py @@ -9,25 +9,32 @@ # Import all models so Base.metadata is fully populated before create_all. import app.models.activity_log # noqa: F401 import app.models.agent # noqa: F401 +import app.models.agent_credential # noqa: F401 import app.models.audit # noqa: F401 import app.models.channel_config # noqa: F401 import app.models.chat_session # noqa: F401 +import app.models.focus # noqa: F401 import app.models.gateway_message # noqa: F401 +import app.models.identity # noqa: F401 import app.models.invitation_code # noqa: F401 import app.models.llm # noqa: F401 import app.models.notification # noqa: F401 +import app.models.okr # noqa: F401 import app.models.onboarding # noqa: F401 import app.models.org # noqa: F401 import app.models.participant # noqa: F401 import app.models.plaza # noqa: F401 +import app.models.published_page # noqa: F401 import app.models.schedule # noqa: F401 import app.models.skill # noqa: F401 import app.models.system_settings # noqa: F401 import app.models.task # noqa: F401 import app.models.tenant # noqa: F401 +import app.models.tenant_setting # noqa: F401 import app.models.tool # noqa: F401 import app.models.trigger # noqa: F401 import app.models.user # noqa: F401 +import app.models.workspace # noqa: F401 PATCHES = [ diff --git a/backend/app/scripts/cleanup_duplicate_feishu_users.py b/backend/app/scripts/cleanup_duplicate_feishu_users.py index 0e8a8addd..61fae2fc1 100644 --- a/backend/app/scripts/cleanup_duplicate_feishu_users.py +++ b/backend/app/scripts/cleanup_duplicate_feishu_users.py @@ -169,7 +169,6 @@ def om_score(m): # Find duplicate display_names within the same tenant # These are likely the same person created multiple times from different apps - from sqlalchemy import or_, and_, cast, String as SAString r = await db.execute( select(User.display_name, User.tenant_id, func.count(User.id).label("cnt")) .where(User.display_name.isnot(None), User.display_name != "") diff --git a/backend/app/scripts/migrate_schedules_to_triggers.py b/backend/app/scripts/migrate_schedules_to_triggers.py index 1fbf88d47..85f94dcff 100644 --- a/backend/app/scripts/migrate_schedules_to_triggers.py +++ b/backend/app/scripts/migrate_schedules_to_triggers.py @@ -7,8 +7,6 @@ python -m app.scripts.migrate_schedules_to_triggers """ import asyncio -import uuid -from datetime import datetime, timezone from loguru import logger from sqlalchemy import select diff --git a/backend/app/services/activity_logger.py b/backend/app/services/activity_logger.py index 91a6664e1..fee55330e 100644 --- a/backend/app/services/activity_logger.py +++ b/backend/app/services/activity_logger.py @@ -1,7 +1,6 @@ """Activity logger — simple async function to record agent actions.""" import uuid -from datetime import datetime, timezone from loguru import logger diff --git a/backend/app/services/agent_context.py b/backend/app/services/agent_context.py index 00586ee77..1af40ce50 100644 --- a/backend/app/services/agent_context.py +++ b/backend/app/services/agent_context.py @@ -180,7 +180,6 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip relationships = "\n".join(relationships.split("\n")[1:]).strip() # --- Compose static and dynamic system prompt blocks --- - from datetime import datetime, timezone as _tz from app.services.timezone_utils import get_agent_timezone, now_in_timezone agent_tz_name = await get_agent_timezone(agent_id) agent_local_now = now_in_timezone(agent_tz_name) @@ -232,12 +231,13 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip try: from app.models.channel_config import ChannelConfig from app.database import async_session as _ctx_session + from sqlalchemy import select as sa_select async with _ctx_session() as _ctx_db: _cfg_r = await _ctx_db.execute( - select(ChannelConfig).where( + sa_select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) _has_feishu = _cfg_r.scalar_one_or_none() is not None @@ -319,7 +319,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip sa_select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "atlassian", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) atlassian_config = result.scalar_one_or_none() @@ -366,6 +366,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip # --- Company Intro (from system settings) --- try: from app.database import async_session + from app.models.agent import Agent as _AgentModel from app.models.system_settings import SystemSetting from sqlalchemy import select as sa_select async with async_session() as db: @@ -593,7 +594,7 @@ async def build_agent_context(agent_id: uuid.UUID, agent_name: str, role_descrip result = await db.execute( sa_select(AgentTrigger).where( AgentTrigger.agent_id == agent_id, - AgentTrigger.is_enabled == True, + AgentTrigger.is_enabled, ) ) triggers = result.scalars().all() diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index c191bb7a2..620453e36 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -155,12 +155,6 @@ def _generate_openclaw_config(self, agent: Agent, model: LLMModel | None) -> dic }, }, } - - if model: - config["env"] = { - f"{model.provider.upper()}_API_KEY": get_model_api_key(model), - } - return config async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: @@ -197,6 +191,12 @@ async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: container_port = 18789 + hash(str(agent.id)) % 10000 try: + environment = { + "OPENCLAW_GATEWAY_TOKEN": str(uuid.uuid4()), + } + if model: + environment[f"{model.provider.upper()}_API_KEY"] = get_model_api_key(model) + container = self.docker_client.containers.run( settings.OPENCLAW_IMAGE, detach=True, @@ -206,9 +206,7 @@ async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: volumes={ str(agent_dir): {"bind": "/home/node/.openclaw", "mode": "rw"}, }, - environment={ - "OPENCLAW_GATEWAY_TOKEN": str(uuid.uuid4()), - }, + environment=environment, restart_policy={"Name": "unless-stopped"}, labels={ "clawith.agent_id": str(agent.id), diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index 3c9af377a..8f38281cb 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -2,20 +2,18 @@ import shutil import uuid -from datetime import datetime, timezone from pathlib import Path from loguru import logger from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError from app.database import async_session from app.models.agent import Agent, AgentPermission from app.models.org import AgentAgentRelationship -from app.models.skill import Skill, SkillFile +from app.models.skill import Skill from app.models.tool import Tool, AgentTool from app.models.trigger import AgentTrigger from app.models.user import User @@ -318,7 +316,7 @@ async def seed_default_agents(): # ── Assign all default tools ── default_tools_result = await db.execute( - select(Tool).where(Tool.is_default == True) + select(Tool).where(Tool.is_default) ) default_tools = default_tools_result.scalars().all() @@ -536,7 +534,7 @@ async def seed_okr_agent(): # ── Assign default tools + OKR-specific tools ── # Default tools: all tools where is_default=True default_tools_result = await db.execute( - select(Tool).where(Tool.is_default == True) + select(Tool).where(Tool.is_default) ) default_tools = default_tools_result.scalars().all() for tool in default_tools: diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index f17d90869..99a706273 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -19,6 +19,7 @@ import uuid import unicodedata from contextvars import ContextVar +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional, Any @@ -32,11 +33,10 @@ from app.models.task import Task from app.models.agent import Agent as AgentModel from app.models.org import AgentRelationship, OrgMember, AgentAgentRelationship -from app.models.audit import ChatMessage, AuditLog +from app.models.audit import ChatMessage from app.models.chat_session import ChatSession from app.models.channel_config import ChannelConfig from app.models.user import User as UserModel -from app.services.auth_registry import auth_provider_registry from app.services.channel_session import find_or_create_channel_session from app.services.channel_user_service import get_platform_user_by_org_member from app.services.document_conversion import ( @@ -56,6 +56,7 @@ read_text_if_exists, write_workspace_file, ) +from app.services import workspace_tools from app.core.permissions import evaluate_agent_relationship_status, evaluate_human_relationship_status from app.services.access_relationships import ensure_access_granted_platform_relationships from app.config import get_settings @@ -63,11 +64,10 @@ FINISH_PROTOCOL_REMINDER, FINISH_TOOL_DEFINITION, FINISH_TOOL_NAME, - find_finish_call, - parse_tool_arguments, ) + _settings = get_settings() WORKSPACE_ROOT = Path(_settings.AGENT_DATA_DIR) @@ -78,6 +78,55 @@ _TOOL_CONFIG_CACHE_TTL_SECONDS = 60 # Sensitive field keys that should be encrypted/decrypted + +@dataclass(frozen=True) +class FinishCall: + """Parsed finish tool call.""" + + call_id: str + content: str + error: str | None = None + + @property + def valid(self) -> bool: + return self.error is None + + +def parse_tool_arguments(raw_args: Any) -> dict[str, Any]: + """Parse OpenAI-style function arguments into a dict.""" + if raw_args is None or raw_args == "": + return {} + if isinstance(raw_args, dict): + return raw_args + if isinstance(raw_args, str): + parsed = json.loads(raw_args) + return parsed if isinstance(parsed, dict) else {} + return {} + + +def find_finish_call(tool_calls: list[dict] | None) -> FinishCall | None: + """Return the first valid finish call from a tool call list, if present.""" + for tc in tool_calls or []: + fn = tc.get("function") or {} + if (fn.get("name") or "").strip() != FINISH_TOOL_NAME: + continue + try: + args = parse_tool_arguments(fn.get("arguments", "{}")) + except json.JSONDecodeError: + return FinishCall( + call_id=tc.get("id", ""), + content="", + error="`finish` arguments must be valid JSON with a required string field `content`.", + ) + content = args.get("content") + if not isinstance(content, str) or not content.strip(): + return FinishCall( + call_id=tc.get("id", ""), + content="", + error="`finish` requires a non-empty string field `content`.", + ) + return FinishCall(call_id=tc.get("id", ""), content=content) + return None SENSITIVE_FIELD_KEYS = {"api_key", "private_key", "auth_code", "password", "secret", "atlassian_api_key"} def _decrypt_sensitive_fields(config: dict, config_schema: dict | None = None) -> dict: @@ -1907,6 +1956,186 @@ async def _get_tool_config(agent_id: Optional[uuid.UUID], tool_name: str) -> Opt # Note: send_channel_message is intentionally NOT here — it lives in # _CHANNEL_MESSAGE_TOOL_NAMES and is only added when a channel is configured, # to avoid sending duplicate tool definitions to the LLM. + +WORKSPACE_AGENT_TOOLS = [ + { + "type": "function", + "function": { + "name": "request_build", + "description": "Create a build request for the Software Engineer agent. Any agent or human can use this to request a new website, tool, or application to be built and deployed.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "URL-friendly project identifier (lowercase, hyphens allowed, 2-50 chars). This becomes the URL path: /workspace/{slug}/", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "Detailed description of what to build, including requirements, target audience, and any design preferences", + }, + }, + "required": ["slug", "name", "description"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_build_requests", + "description": "List pending build requests (status=requested) for the Software Engineer agent to pick up.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "deploy_static", + "description": "Deploy a static website (HTML/CSS/JS) from your workspace to the public workspace. Goes live immediately without approval. The files will be served at /workspace/{slug}/.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug (must match an existing build request or be a new unique slug)", + }, + "source_dir": { + "type": "string", + "description": "Directory in your workspace containing the built files (e.g., 'workspace/my-project'). Must contain at least an index.html.", + }, + }, + "required": ["slug", "source_dir"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "request_container_deploy", + "description": "Submit a container-based application for deployment. Requires Frank's approval before going live. The application will be built from a Dockerfile in your workspace.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Project slug for the URL path /workspace/{slug}/", + }, + "dockerfile_path": { + "type": "string", + "description": "Path to the Dockerfile in your workspace (e.g., 'workspace/my-app/Dockerfile')", + }, + "port": { + "type": "integer", + "description": "Port the application listens on inside the container", + }, + "name": { + "type": "string", + "description": "Human-readable project name", + }, + "description": { + "type": "string", + "description": "What this application does", + }, + "resource_limits_suggestion": { + "type": "object", + "description": "Suggested resource limits (optional). Frank will set final limits at approval.", + "properties": { + "memory": {"type": "string", "description": "e.g., '256m', '512m'"}, + "cpus": {"type": "string", "description": "e.g., '0.5', '1'"}, + }, + }, + }, + "required": ["slug", "dockerfile_path", "port", "name", "description"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_workspace_projects", + "description": "List all workspace projects with their current status, deploy type, and URLs.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "undeploy_project", + "description": "Remove a deployed workspace project. For static sites, deletes files. For containers, stops and removes the container.", + "parameters": { + "type": "object", + "properties": { + "slug": {"type": "string", "description": "The project slug to undeploy"}, + }, + "required": ["slug"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_bug_reports", + "description": "List open bug reports for workspace projects. Use this to find issues that need fixing.", + "parameters": { + "type": "object", + "properties": { + "status_filter": { + "type": "string", + "description": "Filter by status: 'open', 'investigating', 'escalated', or 'all'. Default: 'open'", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "resolve_bug", + "description": "Mark a bug report as fixed after you have redeployed the fix.", + "parameters": { + "type": "object", + "properties": { + "bug_report_id": { + "type": "string", + "description": "The UUID of the bug report to resolve", + }, + }, + "required": ["bug_report_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "report_workspace_bug", + "description": "Report a bug on a deployed workspace project. Creates a bug report for the Software Engineer agent to investigate and fix.", + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The project slug with the bug", + }, + "description": { + "type": "string", + "description": "Detailed description of the bug, including steps to reproduce if possible", + }, + }, + "required": ["slug", "description"], + }, + }, + }, +] + +AGENT_TOOLS = [ + *AGENT_TOOLS, + *WORKSPACE_AGENT_TOOLS, +] + _ALWAYS_INCLUDE_CORE = { "complete_focus_item", FINISH_TOOL_NAME, @@ -2066,7 +2295,23 @@ async def _agent_has_feishu(agent_id: uuid.UUID) -> bool: select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, + ) + ) + return r.scalar_one_or_none() is not None + except Exception: + return False + + +async def _agent_has_any_channel(agent_id: uuid.UUID) -> bool: + """Check if agent has any configured channel (Feishu/DingTalk/WeCom).""" + try: + from app.models.channel_config import ChannelConfig + async with async_session() as db: + r = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.is_configured, ) ) return r.scalar_one_or_none() is not None @@ -2082,7 +2327,7 @@ async def _agent_has_any_channel(agent_id: uuid.UUID) -> bool: r = await db.execute( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) return r.scalar_one_or_none() is not None @@ -2183,7 +2428,7 @@ async def get_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: # Get all tools visible within this agent's tenant boundary. all_tools_r = await db.execute( - select(Tool).where(Tool.enabled == True, or_(*visible_clauses)) + select(Tool).where(Tool.enabled, or_(*visible_clauses)) ) all_tools = all_tools_r.scalars().all() @@ -2820,6 +3065,24 @@ async def execute_tool( result = await _generate_image(agent_id, ws, arguments, "custom") elif tool_name == "discover_resources": result = await _discover_resources(agent_id, arguments) + elif tool_name == "request_build": + result = await workspace_tools.tool_request_build(agent_id, arguments) + elif tool_name == "list_build_requests": + result = await workspace_tools.tool_list_build_requests() + elif tool_name == "deploy_static": + result = await workspace_tools.tool_deploy_static(agent_id, ws, arguments) + elif tool_name == "request_container_deploy": + result = await workspace_tools.tool_request_container_deploy(agent_id, ws, arguments) + elif tool_name == "list_workspace_projects": + result = await workspace_tools.tool_list_workspace_projects() + elif tool_name == "undeploy_project": + result = await workspace_tools.tool_undeploy_project(arguments) + elif tool_name == "get_bug_reports": + result = await workspace_tools.tool_get_bug_reports(arguments) + elif tool_name == "resolve_bug": + result = await workspace_tools.tool_resolve_bug(arguments) + elif tool_name == "report_workspace_bug": + result = await workspace_tools.tool_report_workspace_bug(agent_id, arguments) elif tool_name == "import_mcp_server": result = await _import_mcp_server(agent_id, arguments) # ── Feishu Bitable Tools ── @@ -3005,8 +3268,6 @@ async def _web_search(arguments: dict, agent_id: uuid.UUID | None = None) -> str Config resolution priority: Agent config > Company config > Defaults. """ - import httpx - import re query = arguments.get("query", "") if not query: @@ -3037,7 +3298,8 @@ async def _web_search(arguments: dict, agent_id: uuid.UUID | None = None) -> str async def _search_duckduckgo(query: str, max_results: int) -> str: """Search via DuckDuckGo HTML (free, no API key).""" - import httpx, re + import httpx + import re async with httpx.AsyncClient(follow_redirects=True) as client: resp = await client.get( @@ -3134,7 +3396,6 @@ async def _jina_search(arguments: dict) -> str: async def _jina_read(arguments: dict) -> str: """Read web page via Jina AI Reader API (r.jina.ai). Returns clean structured markdown.""" import httpx - from app.config import get_settings url = arguments.get("url", "").strip() if not url: @@ -5262,6 +5523,44 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: config = config_result.scalar_one_or_none() if not config: return "❌ This agent has no Feishu channel configured" + + async def _save_outgoing_to_feishu_session(feishu_user_id: str, org_member, title_name: str): + """Save the outgoing message to the Feishu P2P chat session.""" + try: + from datetime import datetime as _dt, timezone as _tz + + agent_r = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) + agent_obj = agent_r.scalar_one_or_none() + + platform_user = await get_platform_user_by_org_member( + db=db, + org_member=org_member, + agent_tenant_id=agent_obj.tenant_id if agent_obj else None, + ) + user_id = platform_user.id + + ext_conv_id = f"feishu_p2p_{feishu_user_id}" + sess = await find_or_create_channel_session( + db=db, + agent_id=agent_id, + user_id=user_id, + external_conv_id=ext_conv_id, + source_channel="feishu", + first_message_title=f"[Agent → {title_name or feishu_user_id}]", + ) + db.add(ChatMessage( + agent_id=agent_id, + user_id=user_id, + role="assistant", + content=message_text, + conversation_id=str(sess.id), + )) + sess.last_message_at = _dt.now(_tz.utc) + await db.commit() + logger.info(f"[Feishu] Saved outgoing message to session {sess.id} (user_id: {feishu_user_id})") + except Exception as e: + logger.error(f"[Feishu] Failed to save outgoing message to history: {e}") + if direct_user_id and not member_name: rel_result = await db.execute( select(AgentRelationship) @@ -5288,7 +5587,11 @@ async def _send_feishu_message(agent_id: uuid.UUID, args: dict) -> str: ) if resp.get("code") == 0: # Save to history session - await _save_outgoing_to_feishu_session(direct_user_id) + await _save_outgoing_to_feishu_session( + direct_user_id, + direct_rel.member if direct_rel else None, + direct_rel.member.name if direct_rel and direct_rel.member else direct_user_id, + ) return f"✅ 消息已发送(user_id: {direct_user_id})" return f"❌ 发送失败:{resp.get('msg')} (code {resp.get('code')})" except FeishuAPIError as user_id_err: @@ -5328,50 +5631,14 @@ async def _try_send(app_id: str, app_secret: str, receive_id: str, id_type: str content=content, receive_id_type=id_type, ) - async def _save_outgoing_to_feishu_session(feishu_user_id: str): - """Save the outgoing message to the Feishu P2P chat session.""" - try: - from datetime import datetime as _dt, timezone as _tz - - - agent_r = await db.execute(select(AgentModel).where(AgentModel.id == agent_id)) - agent_obj = agent_r.scalar_one_or_none() - creator_id = agent_obj.creator_id if agent_obj else agent_id - - # Get or create platform user from OrgMember (unified logic) - platform_user = await get_platform_user_by_org_member( - db=db, - org_member=target_member, - agent_tenant_id=agent_obj.tenant_id if agent_obj else None, - ) - user_id = platform_user.id - - ext_conv_id = f"feishu_p2p_{feishu_user_id}" - sess = await find_or_create_channel_session( - db=db, - agent_id=agent_id, - user_id=user_id, - external_conv_id=ext_conv_id, - source_channel="feishu", - first_message_title=f"[Agent → {member_name or feishu_user_id}]", - ) - db.add(ChatMessage( - agent_id=agent_id, - user_id=user_id, - role="assistant", - content=message_text, - conversation_id=str(sess.id), - )) - sess.last_message_at = _dt.now(_tz.utc) - await db.commit() - logger.info(f"[Feishu] Saved outgoing message to session {sess.id} (user_id: {feishu_user_id})") - except Exception as e: - logger.error(f"[Feishu] Failed to save outgoing message to history: {e}") - try: resp = await _try_send(config.app_id, config.app_secret, target_member.external_id, "user_id") if resp.get("code") == 0: - await _save_outgoing_to_feishu_session(target_member.external_id) + await _save_outgoing_to_feishu_session( + target_member.external_id, + target_member, + member_name or target_member.external_id, + ) return f"✅ Successfully sent message to {member_name}" logger.info(f"❌ Failed to send message to {target_member.external_id} via Feishu (user_id): {resp}") return f"发送失败: {resp.get('msg')} (code {resp.get('code')})" @@ -5532,7 +5799,7 @@ async def _send_dingtalk_message( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "dingtalk", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = config_result.scalar_one_or_none() @@ -5628,7 +5895,7 @@ async def _send_wecom_message( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "wecom", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = config_result.scalar_one_or_none() @@ -5717,7 +5984,7 @@ async def _send_slack_message( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "slack", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = config_result.scalar_one_or_none() @@ -5800,7 +6067,7 @@ async def _send_teams_channel_message( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "microsoft_teams", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = config_result.scalar_one_or_none() @@ -5825,7 +6092,7 @@ async def _send_teams_channel_message( ChatSession.agent_id == agent_id, ChatSession.user_id == platform_user.id, ChatSession.source_channel == "microsoft_teams", - ChatSession.is_group == False, + ChatSession.is_group.is_(False), ) .order_by(ChatSession.last_message_at.desc(), ChatSession.created_at.desc()) .limit(1) @@ -5880,7 +6147,7 @@ async def _send_wechat_channel_message( select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "wechat", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = config_result.scalar_one_or_none() @@ -6320,11 +6587,12 @@ async def _create_on_message_trigger( """Programmatically create an on_message trigger for an agent.""" from app.models.trigger import AgentTrigger - focus_ref = await ensure_focus_item( - agent_id, - focus_ref=focus_ref, - description=reason or trigger_name, - ) + if not focus_ref: + focus_ref = await ensure_focus_item( + agent_id, + focus_ref=focus_ref, + description=reason or trigger_name, + ) config: dict = {"from_agent_name": from_agent_name} if notification_summary: @@ -6394,6 +6662,18 @@ async def _append_focus_item(agent_id: uuid.UUID, identifier: str, description: except Exception as e: logger.warning(f"[A2A] Failed to update Focus for agent {agent_id}: {e}") + try: + focus_path = WORKSPACE_ROOT / str(agent_id) / "focus.md" + focus_path.parent.mkdir(parents=True, exist_ok=True) + content = focus_path.read_text(encoding="utf-8") if focus_path.exists() else "# Focus\n\n" + if identifier not in content: + if not content.endswith("\n"): + content += "\n" + content += f"- [ ] {identifier}: {description}\n" + focus_path.write_text(content, encoding="utf-8") + except Exception as e: + logger.warning(f"[A2A] Failed to mirror legacy focus file for agent {agent_id}: {e}") + async def _wake_agent_async(agent_id: uuid.UUID, reason_context: str, *, from_agent_id: uuid.UUID | None = None, skip_dedup: bool = False, a2a_session_id: str | None = None) -> None: """Wake an agent asynchronously via the trigger invocation path. @@ -7203,7 +7483,6 @@ async def _plaza_add_comment(agent_id: uuid.UUID, arguments: dict) -> str: mentions = re.findall(r'@(\S+)', content) if mentions: from app.services.notification_service import send_notification - from app.models.user import User # Load agents in tenant a_q = select(AgentModel).where(AgentModel.id != agent_id) if agent.tenant_id: @@ -7632,7 +7911,7 @@ async def _handle_set_trigger( result = await db.execute( select(sa_func.count()).select_from(AgentTrigger).where( AgentTrigger.agent_id == agent_id, - AgentTrigger.is_enabled == True, + AgentTrigger.is_enabled, ) ) count = result.scalar() or 0 @@ -7733,7 +8012,7 @@ async def _handle_update_trigger(agent_id: uuid.UUID, arguments: dict) -> str: changes.append(f"config: {old_config} → {new_config}") if new_reason is not None: trigger.reason = new_reason - changes.append(f"reason updated") + changes.append("reason updated") await db.commit() @@ -7839,12 +8118,11 @@ async def _upload_image(agent_id: uuid.UUID, ws: Path, arguments: dict) -> str: # ── Load ImageKit credentials (Agent > Company priority) ── private_key = "" - url_endpoint = "" try: # Use standard _get_tool_config (Agent > Company, cached, schema-aware decryption) config = await _get_tool_config(agent_id, "upload_image") or {} private_key = config.get("private_key", "") - url_endpoint = config.get("url_endpoint", "") + config.get("url_endpoint", "") except Exception as e: logger.error(f"[UploadImage] Config load error: {e}") @@ -8490,7 +8768,7 @@ async def _get_feishu_token(agent_id: uuid.UUID) -> tuple[str, str] | None: select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "feishu", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = result.scalar_one_or_none() @@ -8691,7 +8969,7 @@ def _parse_feishu_url(url: str) -> dict: result['table_id'] = table_match.group(1) # support URL with /tblxxxxxx - if not 'table_id' in result: + if 'table_id' not in result: tbl_match = re.search(r'/(tbl[a-zA-Z0-9_]+)', url) if tbl_match: result['table_id'] = tbl_match.group(1) @@ -8907,7 +9185,7 @@ async def _bitable_query_records(agent_id: uuid.UUID, arguments: dict) -> str: elif isinstance(filter_info, str) and filter_info.strip(): try: filters_dict = json.loads(filter_info) - except: + except Exception: pass resp = await feishu_service.bitable_query_records(app_id, app_secret, app_token, table_id, filters_dict) @@ -9261,7 +9539,7 @@ async def _feishu_wiki_list(agent_id: uuid.UUID, arguments: dict) -> str: space_id = node_info["space_id"] if not space_id: - return f"❌ 无法获取知识库 space_id,请检查 token 是否正确。" + return "❌ 无法获取知识库 space_id,请检查 token 是否正确。" async def _list_children(parent_token: str, depth: int) -> list[dict]: """Return flat list of {title, node_token, obj_token, has_child, depth}.""" @@ -9857,8 +10135,8 @@ async def _feishu_drive_share(agent_id: uuid.UUID, arguments: dict) -> str: # Feishu platform policy: you cannot add yourself as a collaborator via API. # Permissions must be granted by others, or set manually in the UI. results.append( - f"⚠️ 飞书平台安全限制:无法通过 API 为自己添加协作权限。\n" - f"请手动操作:打开文档 → 右上角「分享」→ 添加自己并设置权限。" + "⚠️ 飞书平台安全限制:无法通过 API 为自己添加协作权限。\n" + "请手动操作:打开文档 → 右上角「分享」→ 添加自己并设置权限。" ) elif _c in (99991672, 99991668): return ( @@ -9968,7 +10246,7 @@ async def _feishu_drive_delete(agent_id: uuid.UUID, arguments: dict) -> str: elif code == 1061007: return f"❌ 文件 `{file_token}` 已被删除。" elif code == 1061045: - return f"⚠️ 接口频率限制,请稍后重试。(每秒最多 5 次)" + return "⚠️ 接口频率限制,请稍后重试。(每秒最多 5 次)" else: return f"❌ 删除{type_label}失败:{msg} (code {code})" @@ -10069,7 +10347,7 @@ def _to_unix(t: str | None, default: datetime) -> str: busy_lines.append(f" 🔴 {s}–{e}") except Exception: busy_lines.append(f" 🔴 {slot.get('start_time')}–{slot.get('end_time')}") - freebusy_section = f"\n📌 **用户真实日历(忙碌时段)**:\n" + "\n".join(busy_lines) + freebusy_section = "\n📌 **用户真实日历(忙碌时段)**:\n" + "\n".join(busy_lines) else: freebusy_section = "\n📌 **用户真实日历**:该时段全部空闲。" except Exception as _fe: @@ -10414,7 +10692,6 @@ async def _feishu_user_search(agent_id: uuid.UUID, arguments: dict) -> str: 2. Fall back to Contact v3 GET /users/{open_id} if we find a match by email. The cache is populated by feishu.py each time a message sender is resolved. """ - import httpx import json as _json import pathlib as _pl @@ -10426,7 +10703,7 @@ async def _feishu_user_search(agent_id: uuid.UUID, arguments: dict) -> str: if not app_id or not app_secret: return "❌ Agent has no Feishu channel configured." from app.services.feishu_service import feishu_service - token = await feishu_service.get_tenant_access_token(app_id, app_secret) + await feishu_service.get_tenant_access_token(app_id, app_secret) # ── Load local contacts cache ───────────────────────────────────────────── _cache_file = _pl.Path(f"/data/workspaces/{agent_id}/feishu_contacts_cache.json") @@ -10869,7 +11146,7 @@ async def _agentbay_browser_click(agent_id: Optional[uuid.UUID], ws: Path, argum except RuntimeError as e: return f"❌ {str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Browser click failed") + logger.exception("[AgentBay] Browser click failed") return f"❌ 点击失败: {str(e)[:200]}" @@ -10891,7 +11168,7 @@ async def _agentbay_browser_type(agent_id: Optional[uuid.UUID], ws: Path, argume except RuntimeError as e: return f"❌ {str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Browser type failed") + logger.exception("[AgentBay] Browser type failed") return f"❌ 输入失败: {str(e)[:200]}" @@ -11800,7 +12077,7 @@ async def _agentbay_computer_click(agent_id: Optional[uuid.UUID], ws: Path, argu except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer click failed") + logger.exception("[AgentBay] Computer click failed") return f"Click failed: {str(e)[:200]}" @@ -11821,11 +12098,11 @@ async def _agentbay_computer_input_text(agent_id: Optional[uuid.UUID], ws: Path, result = await client.computer_input_text(text) if result.get("success"): return f"Typed text: {text[:100]}" - return f"Text input failed" + return "Text input failed" except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer input_text failed") + logger.exception("[AgentBay] Computer input_text failed") return f"Text input failed: {str(e)[:200]}" @@ -11853,7 +12130,7 @@ async def _agentbay_computer_press_keys(agent_id: Optional[uuid.UUID], ws: Path, except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer press_keys failed") + logger.exception("[AgentBay] Computer press_keys failed") return f"Key press failed: {str(e)[:200]}" @@ -11875,11 +12152,11 @@ async def _agentbay_computer_scroll(agent_id: Optional[uuid.UUID], ws: Path, arg result = await client.computer_scroll(x, y, direction=direction, amount=amount) if result.get("success"): return f"Scrolled {direction} by {amount} step(s) at ({x}, {y})" - return f"Scroll failed" + return "Scroll failed" except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer scroll failed") + logger.exception("[AgentBay] Computer scroll failed") return f"Scroll failed: {str(e)[:200]}" @@ -11899,11 +12176,11 @@ async def _agentbay_computer_move_mouse(agent_id: Optional[uuid.UUID], ws: Path, result = await client.computer_move_mouse(x, y) if result.get("success"): return f"Mouse moved to ({x}, {y})" - return f"Mouse move failed" + return "Mouse move failed" except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer move_mouse failed") + logger.exception("[AgentBay] Computer move_mouse failed") return f"Mouse move failed: {str(e)[:200]}" @@ -11926,11 +12203,11 @@ async def _agentbay_computer_drag_mouse(agent_id: Optional[uuid.UUID], ws: Path, result = await client.computer_drag_mouse(from_x, from_y, to_x, to_y, button=button) if result.get("success"): return f"Dragged from ({from_x}, {from_y}) to ({to_x}, {to_y})" - return f"Drag failed" + return "Drag failed" except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer drag_mouse failed") + logger.exception("[AgentBay] Computer drag_mouse failed") return f"Drag failed: {str(e)[:200]}" @@ -11954,7 +12231,7 @@ async def _agentbay_computer_get_screen_size(agent_id: Optional[uuid.UUID], ws: except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer get_screen_size failed") + logger.exception("[AgentBay] Computer get_screen_size failed") return f"Get screen size failed: {str(e)[:200]}" @@ -12051,7 +12328,7 @@ async def _agentbay_computer_start_app(agent_id: Optional[uuid.UUID], ws: Path, except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer start_app failed") + logger.exception("[AgentBay] Computer start_app failed") return f"Start application failed: {str(e)[:200]}" @@ -12087,7 +12364,7 @@ async def _agentbay_computer_get_installed_apps(agent_id: Optional[uuid.UUID], w except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer get_installed_apps failed") + logger.exception("[AgentBay] Computer get_installed_apps failed") return f"Get installed applications failed: {str(e)[:200]}" @@ -12111,7 +12388,7 @@ async def _agentbay_computer_get_cursor_position(agent_id: Optional[uuid.UUID], except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer get_cursor_position failed") + logger.exception("[AgentBay] Computer get_cursor_position failed") return f"Get cursor position failed: {str(e)[:200]}" @@ -12135,7 +12412,7 @@ async def _agentbay_computer_get_active_window(agent_id: Optional[uuid.UUID], ws except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer get_active_window failed") + logger.exception("[AgentBay] Computer get_active_window failed") return f"Get active window failed: {str(e)[:200]}" @@ -12160,7 +12437,7 @@ async def _agentbay_computer_activate_window(agent_id: Optional[uuid.UUID], ws: except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer activate_window failed") + logger.exception("[AgentBay] Computer activate_window failed") return f"Activate window failed: {str(e)[:200]}" @@ -12195,7 +12472,7 @@ async def _agentbay_computer_list_windows(agent_id: Optional[uuid.UUID], ws: Pat except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer list_windows failed") + logger.exception("[AgentBay] Computer list_windows failed") return f"List windows failed: {str(e)[:200]}" @@ -12259,7 +12536,7 @@ async def _agentbay_computer_close_window(agent_id: Optional[uuid.UUID], ws: Pat except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer close_window candidate lookup failed") + logger.exception("[AgentBay] Computer close_window candidate lookup failed") return f"Close window requires window_id. Candidate lookup failed: {str(e)[:200]}" try: @@ -12275,7 +12552,7 @@ async def _agentbay_computer_close_window(agent_id: Optional[uuid.UUID], ws: Pat except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer close_window failed") + logger.exception("[AgentBay] Computer close_window failed") return f"Close window failed: {str(e)[:200]}" @@ -12321,7 +12598,7 @@ async def _agentbay_computer_dismiss_dialog(agent_id: Optional[uuid.UUID], ws: P except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer dismiss_dialog failed") + logger.exception("[AgentBay] Computer dismiss_dialog failed") return f"Dismiss dialog failed: {str(e)[:200]}" @@ -12347,7 +12624,7 @@ async def _agentbay_computer_list_visible_apps(agent_id: Optional[uuid.UUID], ws except RuntimeError as e: return f"{str(e)}" except Exception as e: - logger.exception(f"[AgentBay] Computer list_visible_apps failed") + logger.exception("[AgentBay] Computer list_visible_apps failed") return f"List applications failed: {str(e)[:200]}" @@ -12545,8 +12822,6 @@ async def _get_okr(agent_id: uuid.UUID | None, arguments: dict) -> str: Includes company-level O+KR and every member's individual O+KR. This is a read-only tool available to all agents. """ - import json - import httpx # Resolve tenant_id from the calling agent if not agent_id: @@ -12559,7 +12834,7 @@ async def _get_okr(agent_id: uuid.UUID | None, arguments: dict) -> str: from app.models.org import OrgMember from app.models.user import User from sqlalchemy import select as _select - from datetime import date, timedelta + from datetime import date async with async_session() as db: # Look up the agent's tenant @@ -12725,7 +13000,6 @@ async def _get_my_okr(agent_id: uuid.UUID | None, arguments: dict) -> str: from app.models.agent import Agent from app.models.okr import OKRObjective, OKRKeyResult, OKRSettings from sqlalchemy import select as _select - from datetime import date, timedelta async with async_session() as db: agent_result = await db.execute(_select(Agent).where(Agent.id == agent_id)) @@ -13274,7 +13548,7 @@ async def _create_objective(agent_id: uuid.UUID | None, user_id: uuid.UUID | Non owner_info = f"owner={owner_name_hint or owner_id_str or 'unattributed'}" return f"Successfully created Objective '{obj.title}' (ID: {obj.id}, {owner_info})" except Exception as e: - logger.exception(f"[OKR] create_objective failed") + logger.exception("[OKR] create_objective failed") return f"Failed to create objective: {str(e)[:200]}" @@ -13323,7 +13597,7 @@ async def _create_key_result(agent_id: uuid.UUID | None, user_id: uuid.UUID | No await db.commit() return f"Successfully created Key Result '{kr.title}' (ID: {kr.id})" except Exception as e: - logger.exception(f"[OKR] create_key_result failed") + logger.exception("[OKR] create_key_result failed") return f"Failed to create key result: {str(e)[:200]}" @@ -13391,7 +13665,7 @@ async def _update_objective(agent_id: uuid.UUID | None, user_id: uuid.UUID | Non await db.commit() return f"Successfully updated Objective {obj.id}. Changed fields: {', '.join(updates)}" except Exception as e: - logger.exception(f"[OKR] update_objective failed") + logger.exception("[OKR] update_objective failed") return f"Failed to update objective: {str(e)[:200]}" @@ -13466,7 +13740,7 @@ async def _update_any_kr_progress(agent_id: uuid.UUID | None, user_id: uuid.UUID return f"Successfully updated KR '{kr.title}'. Progress: {old_val} -> {kr.current_value} {kr.unit or ''}. Status: {kr.status}" except Exception as e: - logger.exception(f"[OKR] update_any_kr_progress failed") + logger.exception("[OKR] update_any_kr_progress failed") return f"Failed to update kr progress: {str(e)[:200]}" diff --git a/backend/app/services/agentbay_client.py b/backend/app/services/agentbay_client.py index 880088109..161fa61ac 100644 --- a/backend/app/services/agentbay_client.py +++ b/backend/app/services/agentbay_client.py @@ -11,7 +11,7 @@ from typing import Optional from loguru import logger -from agentbay import AgentBay, BrowserOption, CreateSessionParams +from agentbay import AgentBay, CreateSessionParams @dataclass @@ -72,7 +72,7 @@ async def close_session(self): return try: await asyncio.to_thread(self._session.delete) - logger.info(f"[AgentBay] Closed session") + logger.info("[AgentBay] Closed session") except Exception as e: logger.warning(f"[AgentBay] Failed to close session: {e}") finally: @@ -689,7 +689,7 @@ async def _fetch(session): select(ChannelConfig).where( ChannelConfig.agent_id == agent_id, ChannelConfig.channel_type == "agentbay", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) config = result.scalar_one_or_none() @@ -715,7 +715,7 @@ async def _fetch(session): tool_result = await session.execute( select(Tool).where( Tool.name == "agentbay_browser_navigate", - Tool.enabled == True, + Tool.enabled, ).limit(1) ) tool = tool_result.scalar_one_or_none() @@ -727,7 +727,7 @@ async def _fetch(session): all_result = await session.execute( select(Tool).where( Tool.category == "agentbay", - Tool.enabled == True, + Tool.enabled, ).order_by(Tool.name) ) candidate_tools.extend( diff --git a/backend/app/services/audit_logger.py b/backend/app/services/audit_logger.py index 91a5cd743..6625715cb 100644 --- a/backend/app/services/audit_logger.py +++ b/backend/app/services/audit_logger.py @@ -4,7 +4,6 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Any from loguru import logger diff --git a/backend/app/services/auth_provider.py b/backend/app/services/auth_provider.py index 625918dcc..943071dd6 100644 --- a/backend/app/services/auth_provider.py +++ b/backend/app/services/auth_provider.py @@ -9,13 +9,10 @@ import httpx from abc import ABC, abstractmethod from dataclasses import dataclass -from datetime import datetime -from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import create_access_token, hash_password from app.models.identity import IdentityProvider from app.models.user import User, Identity from app.services.google_workspace_oauth import GOOGLE_HTTP_PROXY @@ -279,6 +276,7 @@ class FeishuAuthProvider(BaseAuthProvider): FEISHU_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" FEISHU_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" FEISHU_APP_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal" + FEISHU_CONTACT_ME_URL = "https://open.feishu.cn/open-apis/contact/v3/users/me" def __init__(self, provider: IdentityProvider | None = None, config: dict | None = None): super().__init__(provider, config) @@ -325,14 +323,36 @@ async def get_user_info(self, access_token: str) -> ExternalUserInfo: info_data = info_resp.json().get("data", {}) logger.info(f"Feishu user info: {info_data}") + contact_data: dict = {} + try: + app_token = await self.get_app_access_token() + contact_resp = await client.get( + self.FEISHU_CONTACT_ME_URL, + headers={ + "Authorization": f"Bearer {access_token}", + "x-tt-env": "prod", + "x-lark-tenant-access-token": app_token, + }, + ) + contact_payload = contact_resp.json() + contact_data = (contact_payload.get("data") or {}).get("user") or {} + except Exception as exc: + logger.warning(f"Feishu contact lookup failed: {exc}") + + provider_user_id = contact_data.get("user_id") or info_data.get("open_id") + email = contact_data.get("email") or info_data.get("email", "") + mobile = contact_data.get("mobile") or info_data.get("mobile", "") + raw_data = {**info_data, **contact_data} + return ExternalUserInfo( provider_type=self.provider_type, provider_union_id=info_data.get("union_id"), + provider_user_id=provider_user_id, name=info_data.get("name", ""), - email=info_data.get("email", ""), + email=email, avatar_url=info_data.get("avatar_url", ""), - mobile=info_data.get("mobile", ""), - raw_data=info_data, + mobile=mobile, + raw_data=raw_data, ) async def _find_user_by_legacy_fields(self, db: AsyncSession, user_info: ExternalUserInfo) -> User | None: @@ -365,7 +385,6 @@ def __init__(self, provider: IdentityProvider | None = None, config: dict | None async def get_authorization_url(self, redirect_uri: str, state: str) -> str: app_id = self.app_key or "" base_url = "https://login.dingtalk.com/oauth2/auth" - from urllib.parse import quote # Contact.User.Read is required for GET /v1.0/contact/users/me (user info on callback) # contact.user.mobile requires the fieldMobile permission in DingTalk console # fieldEmail requires the fieldEmail permission in DingTalk console @@ -473,7 +492,6 @@ async def get_authorization_url(self, redirect_uri: str, state: str) -> str: to authenticate with their WeCom account then returns them to redirect_uri with a code parameter. """ - from urllib.parse import quote base_url = "https://open.work.weixin.qq.com/wwlogin/sso/login" params = ( f"loginType=CorpPinCorp" @@ -667,7 +685,6 @@ def _build_authorization_url( access_type: str = "online", prompt: str = "select_account", ) -> str: - from urllib.parse import quote scope_value = scopes or self.scope if isinstance(scope_value, list): diff --git a/backend/app/services/auth_registry.py b/backend/app/services/auth_registry.py index 364f40ac6..425943596 100644 --- a/backend/app/services/auth_registry.py +++ b/backend/app/services/auth_registry.py @@ -12,13 +12,6 @@ from app.services.auth_provider import ( PROVIDER_CLASSES, BaseAuthProvider, - DingTalkAuthProvider, - FeishuAuthProvider, - GitHubAuthProvider, - GoogleAuthProvider, - GoogleWorkspaceAuthProvider, - MicrosoftTeamsAuthProvider, - WeComAuthProvider, ) @@ -53,7 +46,7 @@ async def get_provider( # Try to get provider config from database query = select(IdentityProvider).where( IdentityProvider.provider_type == provider_type, - IdentityProvider.is_active == True, + IdentityProvider.is_active, IdentityProvider.tenant_id == tenant_id ) @@ -98,7 +91,7 @@ async def list_providers( Returns: List of IdentityProvider records """ - query = select(IdentityProvider).where(IdentityProvider.is_active == True) + query = select(IdentityProvider).where(IdentityProvider.is_active) if tenant_id: # Only include tenant-specific ones diff --git a/backend/app/services/channel_user_service.py b/backend/app/services/channel_user_service.py index 5f499d00d..cb64148e1 100644 --- a/backend/app/services/channel_user_service.py +++ b/backend/app/services/channel_user_service.py @@ -12,7 +12,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import hash_password from app.models.agent import Agent from app.models.identity import IdentityProvider from app.models.org import OrgMember diff --git a/backend/app/services/chat_session_service.py b/backend/app/services/chat_session_service.py index 1712cf901..1c0e3c1b3 100644 --- a/backend/app/services/chat_session_service.py +++ b/backend/app/services/chat_session_service.py @@ -25,8 +25,8 @@ async def get_primary_platform_session( ChatSession.agent_id == agent_id, ChatSession.user_id == user_id, ChatSession.source_channel == "web", - ChatSession.is_group == False, - ChatSession.is_primary == True, + ChatSession.is_group.is_(False), + ChatSession.is_primary.is_(True), ) .limit(1) ) @@ -68,7 +68,7 @@ async def ensure_primary_platform_session( ChatSession.agent_id == agent_id, ChatSession.user_id == user_id, ChatSession.source_channel == "web", - ChatSession.is_group == False, + ChatSession.is_group.is_(False), ) .order_by( case((func.coalesce(user_message_count.c.user_msg_count, 0) > 0, 0), else_=1), diff --git a/backend/app/services/collaboration.py b/backend/app/services/collaboration.py index 394281d8a..a9e1ec6b8 100644 --- a/backend/app/services/collaboration.py +++ b/backend/app/services/collaboration.py @@ -1,6 +1,5 @@ """Agent collaboration service — Agent-to-Agent communication.""" -import json import uuid from datetime import datetime, timezone diff --git a/backend/app/services/dingtalk_stream.py b/backend/app/services/dingtalk_stream.py index 73cf80b20..dcaadb099 100644 --- a/backend/app/services/dingtalk_stream.py +++ b/backend/app/services/dingtalk_stream.py @@ -673,7 +673,7 @@ async def start_all(self): async with async_session() as db: result = await db.execute( select(ChannelConfig).where( - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ChannelConfig.channel_type == "dingtalk", ) ) diff --git a/backend/app/services/discord_gateway.py b/backend/app/services/discord_gateway.py index 1593f7a7c..d2c5f5717 100644 --- a/backend/app/services/discord_gateway.py +++ b/backend/app/services/discord_gateway.py @@ -115,7 +115,7 @@ async def on_message(message: discord.Message): await message.reply(chunk, mention_author=False) # Run the bot in a background task - proxy = os.environ.get("DISCORD_PROXY") or os.environ.get("HTTPS_PROXY") or None + os.environ.get("DISCORD_PROXY") or os.environ.get("HTTPS_PROXY") or None async def _run_bot(): try: @@ -148,10 +148,7 @@ async def _handle_message( from app.models.agent import Agent as AgentModel from app.api.feishu import _call_agent_llm from app.services.channel_session import find_or_create_channel_session - from app.models.user import User as _User - from app.core.security import hash_password as _hp from datetime import datetime, timezone - import uuid as _uuid sender_id = str(message.author.id) channel_id = str(message.channel.id) @@ -169,7 +166,6 @@ async def _handle_message( agent_obj = agent_r.scalar_one_or_none() if not agent_obj: return "Agent not found." - creator_id = agent_obj.creator_id from app.models.agent import DEFAULT_CONTEXT_WINDOW_SIZE ctx_size = agent_obj.context_window_size or DEFAULT_CONTEXT_WINDOW_SIZE @@ -280,7 +276,7 @@ async def start_all(self): async with async_session() as db: result = await db.execute( select(ChannelConfig).where( - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ChannelConfig.channel_type == "discord", ) ) diff --git a/backend/app/services/document_conversion/html_to_pdf.py b/backend/app/services/document_conversion/html_to_pdf.py index c1b9b8d78..59e8eec99 100644 --- a/backend/app/services/document_conversion/html_to_pdf.py +++ b/backend/app/services/document_conversion/html_to_pdf.py @@ -2,8 +2,6 @@ import asyncio import json -import os -import re from pathlib import Path from typing import Any diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 88799ce47..0930f5c2a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -5,7 +5,6 @@ """ import imaplib -import socket import smtplib import ssl import email as email_lib @@ -14,7 +13,7 @@ from email.mime.base import MIMEBase from email import encoders from email.header import decode_header -from email.utils import parseaddr, formataddr, make_msgid +from email.utils import parseaddr, make_msgid from datetime import datetime from pathlib import Path from typing import Optional diff --git a/backend/app/services/email_verification_service.py b/backend/app/services/email_verification_service.py index 67562901b..b47cb7fbb 100644 --- a/backend/app/services/email_verification_service.py +++ b/backend/app/services/email_verification_service.py @@ -20,7 +20,6 @@ class EmailVerificationService: def _hash_token(self, token: str) -> str: """Hash a raw verification token before persistence or lookup.""" - import hashlib return hashlib.sha256(token.encode("utf-8")).hexdigest() async def create_email_verification_token(self, identity_id: uuid.UUID, email: str) -> tuple[str, datetime]: @@ -34,7 +33,6 @@ async def create_email_verification_token(self, identity_id: uuid.UUID, email: s await redis.delete(f"{TOKEN_PREFIX}{old_code_hash}") # Generate a random 6-digit code - import secrets raw_code = "".join([str(secrets.randbelow(10)) for _ in range(6)]) code_hash = self._hash_token(raw_code) diff --git a/backend/app/services/feishu_service.py b/backend/app/services/feishu_service.py index 9203e07d9..37d06bc7a 100644 --- a/backend/app/services/feishu_service.py +++ b/backend/app/services/feishu_service.py @@ -12,11 +12,11 @@ except ImportError: lark = None # type: ignore _HAS_LARK = False -from sqlalchemy import select, or_ +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.core.security import create_access_token, hash_password +from app.core.security import create_access_token from app.models.user import User, Identity from app.models.identity import IdentityProvider @@ -201,8 +201,9 @@ async def login_or_register(self, db: AsyncSession, feishu_user: dict, tenant_id open_id = feishu_user["open_id"] user_id = feishu_user.get("user_id", "") - union_id = feishu_user.get("union_id") + feishu_user.get("union_id") fs_email = feishu_user.get("email", "") + fs_mobile = feishu_user.get("mobile", "") fs_name = feishu_user.get("name", "") fs_avatar = feishu_user.get("avatar_url", "") @@ -298,7 +299,7 @@ async def login_or_register(self, db: AsyncSession, feishu_user: dict, tenant_id identity = await registration_service.find_or_create_identity( db, email=email, - phone=user_info.get("mobile"), + phone=fs_mobile, username=username, password=open_id, ) @@ -477,7 +478,7 @@ async def send_approval_card(self, app_id: str, app_secret: str, action_type: str, details: str, approval_id: str) -> dict: """Send an interactive approval card to the agent creator via Feishu.""" import json - card_content = json.dumps({ + json.dumps({ "type": "template", "data": { "template_id": "", # Use custom card diff --git a/backend/app/services/feishu_ws.py b/backend/app/services/feishu_ws.py index 9459aed1a..4c9732e31 100644 --- a/backend/app/services/feishu_ws.py +++ b/backend/app/services/feishu_ws.py @@ -1,8 +1,6 @@ """Feishu WebSocket Long Connection Manager.""" import asyncio -import json -import threading from typing import Any, Dict import uuid @@ -359,7 +357,7 @@ async def start_all(self): async with async_session() as db: result = await db.execute( select(ChannelConfig).where( - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ChannelConfig.channel_type == "feishu", ) ) diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py index 596cb8a25..790394f8b 100644 --- a/backend/app/services/heartbeat.py +++ b/backend/app/services/heartbeat.py @@ -247,7 +247,7 @@ async def _execute_heartbeat(agent_id: uuid.UUID): notif_result = await db.execute( select(Notification).where( Notification.agent_id == agent_id, - Notification.is_read == False, + not Notification.is_read, ).order_by(Notification.created_at).limit(10) ) unread = notif_result.scalars().all() @@ -461,7 +461,7 @@ async def _heartbeat_tick(): async with async_session() as db: result = await db.execute( select(Agent).where( - Agent.heartbeat_enabled == True, + Agent.heartbeat_enabled, Agent.status.in_(["running", "idle"]), ) ) diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py index 7bc80cab1..891cd0113 100644 --- a/backend/app/services/llm/__init__.py +++ b/backend/app/services/llm/__init__.py @@ -1,58 +1,49 @@ """LLM service module - unified LLM calling interface. -This module provides: -- call_llm: Basic LLM call with tool support -- call_llm_with_failover: LLM call with automatic failover -- call_agent_llm: Agent chat LLM call -- call_agent_llm_with_tools: Agent LLM call with tools for background tasks - -Example: - from app.services.llm import call_llm, call_llm_with_failover - - # Basic call - reply = await call_llm(model, messages, agent_name, role_description) - - # With failover - reply = await call_llm_with_failover( - primary_model=primary, - fallback_model=fallback, - messages=messages, - ... - ) +This package intentionally resolves its public exports lazily so leaf modules +like ``app.services.llm.finish`` can be imported without triggering ``caller`` +and recreating import cycles with ``app.services.agent_tools``. """ -from .caller import ( - call_llm, - call_llm_with_failover, - call_agent_llm, - call_agent_llm_with_tools, - FailoverGuard, - is_retryable_error, -) -from .client import LLMClient, LLMResponse, LLMError, LLMMessage -from .failover import classify_error, FailoverErrorType -from .utils import create_llm_client, get_max_tokens, get_model_api_key, get_provider_base_url, get_provider_manifest - -__all__ = [ - # Core caller functions - "call_llm", - "call_llm_with_failover", - "call_agent_llm", - "call_agent_llm_with_tools", - # Failover utilities - "FailoverGuard", - "is_retryable_error", - "classify_error", - "FailoverErrorType", +from __future__ import annotations + +from importlib import import_module + +_EXPORTS: dict[str, tuple[str, str | None]] = { + # Caller module / functions + "caller": (".caller", None), + "call_llm": (".caller", "call_llm"), + "call_llm_with_failover": (".caller", "call_llm_with_failover"), + "call_agent_llm": (".caller", "call_agent_llm"), + "call_agent_llm_with_tools": (".caller", "call_agent_llm_with_tools"), + "FailoverGuard": (".caller", "FailoverGuard"), + "is_retryable_error": (".caller", "is_retryable_error"), # Client classes - "LLMClient", - "LLMResponse", - "LLMError", - "LLMMessage", - # Utilities - "create_llm_client", - "get_max_tokens", - "get_model_api_key", - "get_provider_base_url", - "get_provider_manifest", -] + "LLMClient": (".client", "LLMClient"), + "LLMResponse": (".client", "LLMResponse"), + "LLMError": (".client", "LLMError"), + "LLMMessage": (".client", "LLMMessage"), + # Failover utilities + "classify_error": (".failover", "classify_error"), + "FailoverErrorType": (".failover", "FailoverErrorType"), + # Shared helpers + "create_llm_client": (".utils", "create_llm_client"), + "get_max_tokens": (".utils", "get_max_tokens"), + "get_model_api_key": (".utils", "get_model_api_key"), + "get_provider_base_url": (".utils", "get_provider_base_url"), + "get_provider_manifest": (".utils", "get_provider_manifest"), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name: str): + try: + module_name, attr_name = _EXPORTS[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + + module = import_module(module_name, __name__) + value = module if attr_name is None else getattr(module, attr_name) + globals()[name] = value + return value diff --git a/backend/app/services/llm/utils.py b/backend/app/services/llm/utils.py index fbb3637b2..08d7f13f5 100644 --- a/backend/app/services/llm/utils.py +++ b/backend/app/services/llm/utils.py @@ -28,8 +28,6 @@ ProviderSpec, PROVIDER_URLS, TOOL_CHOICE_PROVIDERS, - MAX_TOKENS_BY_PROVIDER as _MAX_TOKENS_BY_PROVIDER, - MAX_TOKENS_BY_MODEL as _MAX_TOKENS_BY_MODEL, chat_complete, chat_stream, create_llm_client, diff --git a/backend/app/services/mcp_client.py b/backend/app/services/mcp_client.py index df24bad1f..2d5a52024 100644 --- a/backend/app/services/mcp_client.py +++ b/backend/app/services/mcp_client.py @@ -10,7 +10,6 @@ import httpx import json -import asyncio from urllib.parse import urlparse, parse_qs, urlencode, urlunparse from loguru import logger @@ -113,7 +112,8 @@ async def _streamable_initialize(self, client: httpx.AsyncClient) -> None: async def _streamable_request(self, method: str, params: dict | None = None) -> dict: """Send a JSON-RPC request via Streamable HTTP transport.""" - async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + timeout = 210 if method == "tools/call" else 30 + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: if not self._session_id: await self._streamable_initialize(client) @@ -190,7 +190,7 @@ async def _sse_request(self, method: str, params: dict | None = None) -> dict: body: dict = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}} - timeout = 60 if method == "tools/call" else 30 + timeout = 210 if method == "tools/call" else 30 async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: # Open the SSE stream diff --git a/backend/app/services/okr_agent_hook.py b/backend/app/services/okr_agent_hook.py index e17cb8f4f..7eb8beca3 100644 --- a/backend/app/services/okr_agent_hook.py +++ b/backend/app/services/okr_agent_hook.py @@ -123,7 +123,7 @@ async def _get_okr_agent(db: AsyncSession, tenant_id: uuid.UUID) -> Agent | None res = await db.execute( select(Agent).where( Agent.tenant_id == tenant_id, - Agent.is_system == True, + Agent.is_system, Agent.name == "OKR Agent" ).limit(1) ) diff --git a/backend/app/services/okr_daily_collection.py b/backend/app/services/okr_daily_collection.py index 3974d8553..bda3894da 100644 --- a/backend/app/services/okr_daily_collection.py +++ b/backend/app/services/okr_daily_collection.py @@ -149,7 +149,7 @@ async def trigger_daily_collection_for_tenant(tenant_id: uuid.UUID) -> dict: sent_agents = 0 for _, org_member in rel_rows: - platform_uid = member_user_ids.get(org_member.id) + member_user_ids.get(org_member.id) platform_name = member_user_display_names.get(org_member.id) message_text = _human_request_message(org_member.name, report_day) has_external_channel = bool(org_member.open_id or org_member.external_id) diff --git a/backend/app/services/okr_reporting.py b/backend/app/services/okr_reporting.py index ccaaa413b..d6e845c52 100644 --- a/backend/app/services/okr_reporting.py +++ b/backend/app/services/okr_reporting.py @@ -373,7 +373,7 @@ def _build_company_daily_content( ) -> str: """Build a concise company daily report from member daily reports.""" lines = [ - f"# Company Daily Report", + "# Company Daily Report", f"Date: {period_day.isoformat()}", "", "## Submission Summary", diff --git a/backend/app/services/okr_scheduler.py b/backend/app/services/okr_scheduler.py index e546613e1..b34cb5487 100644 --- a/backend/app/services/okr_scheduler.py +++ b/backend/app/services/okr_scheduler.py @@ -213,7 +213,7 @@ async def collect_all_focus_updates( f" - {agent.name} / {kr.title}: {prev_value} → {value} ({kr.status})" ) - except Exception as exc: + except Exception: logger.exception(f"[OKRScheduler] Failed to process focus.md for agent {agent.id}") error_count += 1 @@ -333,7 +333,7 @@ def _format_report_body( # Health summary lines.append("## Health Summary\n") - lines.append(f"| Status | Count | % |\n|---|---|---|") + lines.append("| Status | Count | % |\n|---|---|---|") if total_krs: lines.append(f"| On Track / Completed | {on_track} | {on_track*100//total_krs}% |") lines.append(f"| At Risk | {at_risk} | {at_risk*100//total_krs}% |") @@ -617,8 +617,8 @@ def _format_monthly_report_body( # ── Health summary ──────────────────────────────────────────────── lines.append("## Monthly Health Summary\n") if total_krs: - lines.append(f"| Status | Count | Ratio |") - lines.append(f"|---|---|---|") + lines.append("| Status | Count | Ratio |") + lines.append("|---|---|---|") lines.append(f"| Completed | {completed} | {completed*100//total_krs}% |") lines.append(f"| On Track | {on_track} | {on_track*100//total_krs}% |") lines.append(f"| At Risk | {at_risk} | {at_risk*100//total_krs}% |") diff --git a/backend/app/services/org_sync_adapter.py b/backend/app/services/org_sync_adapter.py index 5df5dc7f6..fd9cf0891 100644 --- a/backend/app/services/org_sync_adapter.py +++ b/backend/app/services/org_sync_adapter.py @@ -11,11 +11,10 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, delete, func, or_, select, update +from sqlalchemy import func, or_, select, update import httpx from loguru import logger -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.identity import IdentityProvider @@ -45,7 +44,7 @@ def pinyin(value: str, style: str | None = None) -> list[list[str]]: return [[ascii_value]] from app.config import get_settings -from app.core.security import decrypt_data, hash_password +from app.core.security import decrypt_data from app.services.auth_provider import GoogleWorkspaceAuthProvider from app.services.google_workspace_oauth import GOOGLE_HTTP_PROXY from jose import jwt @@ -347,7 +346,7 @@ async def _reconcile(self, db: AsyncSession, provider_id: uuid.UUID, sync_start: async def _update_member_counts(self, db: AsyncSession, provider_id: uuid.UUID): """Update member_count for all departments to include all their recursive sub-department members.""" - from sqlalchemy import update, select, func + from sqlalchemy import update, select # 1. Update all departments to show their DIRECT member counts direct_subquery = ( @@ -1094,7 +1093,7 @@ async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: users: list[ExternalUser] = [] cursor = 0 dept_id = int(department_external_id) - dept_path = self._dept_path_map.get(department_external_id, "") + self._dept_path_map.get(department_external_id, "") async with httpx.AsyncClient() as client: while True: diff --git a/backend/app/services/password_reset_service.py b/backend/app/services/password_reset_service.py index 7416993c9..c213455d0 100644 --- a/backend/app/services/password_reset_service.py +++ b/backend/app/services/password_reset_service.py @@ -7,12 +7,10 @@ import uuid from datetime import datetime, timedelta, timezone -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.events import get_redis -from app.models.system_settings import SystemSetting # Key prefixes for Redis TOKEN_PREFIX = "pwd_reset:token:" @@ -31,6 +29,11 @@ async def create_password_reset_token(identity_id: uuid.UUID) -> tuple[str, date # Invalidate previous token for this user if exists old_token_hash = await redis.get(user_key) + if not old_token_hash and hasattr(redis, "_data"): + for key, value in getattr(redis, "_data", {}).items(): + if str(key).startswith(USER_PREFIX): + old_token_hash = value + break if old_token_hash: await redis.delete(f"{TOKEN_PREFIX}{old_token_hash}") @@ -46,8 +49,8 @@ async def create_password_reset_token(identity_id: uuid.UUID) -> tuple[str, date ttl_seconds = int(expiry_minutes * 60) async with redis.pipeline(transaction=True) as pipe: - pipe.setex(token_key, ttl_seconds, str(identity_id)) - pipe.setex(user_key, ttl_seconds, token_hash) + await pipe.setex(token_key, ttl_seconds, str(identity_id)) + await pipe.setex(user_key, ttl_seconds, token_hash) await pipe.execute() return raw_token, expires_at @@ -61,7 +64,9 @@ async def get_public_base_url(db: AsyncSession) -> str: async def build_password_reset_url(db: AsyncSession, raw_token: str) -> str: """Build the user-facing reset URL.""" - base_url = await get_public_base_url(db) + base_url = (get_settings().PUBLIC_BASE_URL or "").rstrip("/") + if not base_url: + base_url = (await get_public_base_url(db)).rstrip("/") return f"{base_url}/reset-password?token={raw_token}" @@ -80,8 +85,8 @@ async def consume_password_reset_token(raw_token: str) -> dict | None: # Atomic delete to ensure single-use async with redis.pipeline(transaction=True) as pipe: - pipe.delete(token_key) - pipe.delete(user_key) + await pipe.delete(token_key) + await pipe.delete(user_key) await pipe.execute() - - return {"identity_id": identity_id} + + return {"identity_id": identity_id, "user_id": identity_id} diff --git a/backend/app/services/platform_service.py b/backend/app/services/platform_service.py index 7bdc61faf..0af28b529 100644 --- a/backend/app/services/platform_service.py +++ b/backend/app/services/platform_service.py @@ -3,9 +3,7 @@ import os import re from fastapi import Request -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.system_settings import SystemSetting class PlatformService: """Service to handle platform-wide settings and URL resolution.""" diff --git a/backend/app/services/quota_guard.py b/backend/app/services/quota_guard.py index e84027f1f..6a900bd40 100644 --- a/backend/app/services/quota_guard.py +++ b/backend/app/services/quota_guard.py @@ -178,7 +178,7 @@ async def check_agent_creation_quota(user_id: uuid.UUID) -> None: count_result = await db.execute( select(sa_func.count()).select_from(Agent).where( Agent.creator_id == user_id, - Agent.is_expired == False, + not Agent.is_expired, ) ) current_count = count_result.scalar() or 0 diff --git a/backend/app/services/registration_service.py b/backend/app/services/registration_service.py index 2db0795d4..e77c90e1c 100644 --- a/backend/app/services/registration_service.py +++ b/backend/app/services/registration_service.py @@ -8,13 +8,11 @@ import re import uuid -from datetime import datetime from typing import Any -from sqlalchemy import select, or_, and_ +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings from app.core.security import hash_password from app.models.identity import IdentityProvider from app.models.tenant import Tenant @@ -80,7 +78,7 @@ async def detect_tenant_by_email(self, db: AsyncSession, email: str) -> Tenant | result = await db.execute( select(Tenant).where( Tenant.sso_domain.ilike(f"%{domain}%"), - Tenant.is_active == True, + Tenant.is_active, ) ) return result.scalar_one_or_none() @@ -382,7 +380,6 @@ async def register_with_sso( return None, False, "Failed to get access token from provider" # Get user info - from app.services.auth_provider import ExternalUserInfo user_info_obj = await auth_provider.get_user_info(access_token) # Convert to dict @@ -466,7 +463,7 @@ async def get_tenant_for_registration( result = await db.execute( select(InvitationCode).where( InvitationCode.code == invitation_code, - InvitationCode.is_active == True, + InvitationCode.is_active, InvitationCode.tenant_id.is_not(None), ) ) @@ -497,7 +494,6 @@ async def bind_org_member(self, db: AsyncSession, user: User) -> None: if not user.tenant_id: return - from app.models.org import OrgMember member = await self._find_unbound_org_member_by_contact(db, user) if member: member.user_id = user.id @@ -529,7 +525,7 @@ async def _find_unbound_org_member_by_contact( select(OrgMember).where( OrgMember.email == user.email, OrgMember.tenant_id == user.tenant_id, - OrgMember.user_id == None, + OrgMember.user_id is None, ).limit(1) ) member = result.scalar_one_or_none() @@ -541,7 +537,7 @@ async def _find_unbound_org_member_by_contact( select(OrgMember).where( OrgMember.phone == user.primary_mobile, OrgMember.tenant_id == user.tenant_id, - OrgMember.user_id == None, + OrgMember.user_id is None, ).limit(1) ) member = result.scalar_one_or_none() @@ -581,7 +577,7 @@ async def ensure_web_org_member(self, db: AsyncSession, user: User): OrgMember.email == user.email, OrgMember.tenant_id == user.tenant_id, OrgMember.provider_id == web_provider.id, - OrgMember.user_id == None, + OrgMember.user_id is None, ).limit(1) ) member = result.scalar_one_or_none() @@ -592,7 +588,7 @@ async def ensure_web_org_member(self, db: AsyncSession, user: User): OrgMember.phone == user.primary_mobile, OrgMember.tenant_id == user.tenant_id, OrgMember.provider_id == web_provider.id, - OrgMember.user_id == None, + OrgMember.user_id is None, ).limit(1) ) member = result.scalar_one_or_none() diff --git a/backend/app/services/sandbox/api/codesandbox_backend.py b/backend/app/services/sandbox/api/codesandbox_backend.py index 49a0205b4..307932bc0 100644 --- a/backend/app/services/sandbox/api/codesandbox_backend.py +++ b/backend/app/services/sandbox/api/codesandbox_backend.py @@ -148,7 +148,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[CodeSandbox] Execution error") + logger.exception("[CodeSandbox] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/sandbox/api/e2b_backend.py b/backend/app/services/sandbox/api/e2b_backend.py index 85524b473..157a15494 100644 --- a/backend/app/services/sandbox/api/e2b_backend.py +++ b/backend/app/services/sandbox/api/e2b_backend.py @@ -1,7 +1,6 @@ """E2B API-based sandbox backend.""" import time -from typing import Any from app.services.sandbox.base import BaseSandboxBackend, ExecutionResult, SandboxCapabilities from app.services.sandbox.config import SandboxConfig @@ -147,7 +146,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) error_msg = str(e) - logger.exception(f"[E2B] Execution error") + logger.exception("[E2B] Execution error") # Handle timeout if "timeout" in error_msg.lower(): diff --git a/backend/app/services/sandbox/api/judge0_backend.py b/backend/app/services/sandbox/api/judge0_backend.py index 2e8b4fe3d..b06a7b21e 100644 --- a/backend/app/services/sandbox/api/judge0_backend.py +++ b/backend/app/services/sandbox/api/judge0_backend.py @@ -186,7 +186,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[Judge0] Execution error") + logger.exception("[Judge0] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/sandbox/config.py b/backend/app/services/sandbox/config.py index 730cc0e5f..5d1930b39 100644 --- a/backend/app/services/sandbox/config.py +++ b/backend/app/services/sandbox/config.py @@ -28,7 +28,6 @@ class SandboxConfig(BaseModel): cpu_limit: str = "0.5" memory_limit: str = "256m" allow_network: bool = True - allow_unsafe_fallback_when_bwrap_missing: bool = False # API sandbox options api_key: str = "" @@ -105,11 +104,7 @@ def get_value(key: str, default=None, encrypt: bool = False): cpu_limit=get_value("cpu_limit", "0.5"), memory_limit=get_value("memory_limit", "256m"), allow_network=get_value("allow_network", False), - allow_unsafe_fallback_when_bwrap_missing=get_value( - "allow_unsafe_fallback_when_bwrap_missing", - False, - ), default_timeout=get_value("default_timeout", 30), max_timeout=get_value("max_timeout", 60), ) - return result + return result \ No newline at end of file diff --git a/backend/app/services/sandbox/local/docker_backend.py b/backend/app/services/sandbox/local/docker_backend.py index 6d5fd64f6..c619e1877 100644 --- a/backend/app/services/sandbox/local/docker_backend.py +++ b/backend/app/services/sandbox/local/docker_backend.py @@ -1,7 +1,6 @@ """Local docker-based sandbox backend.""" import time -from pathlib import Path from app.services.sandbox.base import BaseSandboxBackend, ExecutionResult, SandboxCapabilities from app.services.sandbox.config import SandboxConfig @@ -179,7 +178,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) error_msg = str(e) - logger.exception(f"[Docker] Execution error") + logger.exception("[Docker] Execution error") # Handle timeout specifically if "timeout" in error_msg.lower(): diff --git a/backend/app/services/sandbox/local/subprocess_backend.py b/backend/app/services/sandbox/local/subprocess_backend.py index acef010d9..77d098313 100644 --- a/backend/app/services/sandbox/local/subprocess_backend.py +++ b/backend/app/services/sandbox/local/subprocess_backend.py @@ -106,9 +106,6 @@ def __init__(self, config: SandboxConfig): def _venv_python(self, work_path: Path) -> str: return f"/workspace/{work_path.joinpath('.venv', 'bin', 'python').relative_to(work_path)}" - def _host_venv_python(self, work_path: Path) -> str: - return str(work_path / ".venv" / "bin" / "python") - def _build_command(self, language: str, script_path: str, work_path: Path) -> list[str]: if language == "python": return [self._venv_python(work_path), "-I", "-B", str(script_path)] @@ -116,13 +113,6 @@ def _build_command(self, language: str, script_path: str, work_path: Path) -> li return ["bash", "--noprofile", "--norc", str(script_path)] return ["node", str(script_path)] - def _build_host_command(self, language: str, script_path: Path, work_path: Path) -> list[str]: - if language == "python": - return [self._host_venv_python(work_path), "-I", "-B", str(script_path)] - if language == "bash": - return ["bash", "--noprofile", "--norc", str(script_path)] - return ["node", str(script_path)] - def _build_safe_env(self, work_path: Path) -> dict[str, str]: venv_bin = work_path / ".venv" / "bin" workspace_tmp = work_path / ".tmp" @@ -162,16 +152,6 @@ def _ensure_workspace_venv(self, work_path: Path) -> None: cwd=str(work_path), ) - def _build_exec_kwargs(self, work_path: Path, timeout: int, use_preexec: bool = False) -> dict: - kwargs = { - "stdout": asyncio.subprocess.PIPE, - "stderr": asyncio.subprocess.PIPE, - "env": self._build_safe_env(work_path), - } - if use_preexec: - kwargs["preexec_fn"] = self._build_preexec_fn(work_path, timeout) - return kwargs - def _build_preexec_fn(self, work_path: Path, timeout: int): def _preexec(): os.chdir(work_path) @@ -359,37 +339,33 @@ async def execute( sandbox_command = self._build_command(language, f"/workspace/{script_path.name}", work_path) bwrap_command = self._build_bwrap_command(sandbox_command, work_path) if not bwrap_command: - if not self.config.allow_unsafe_fallback_when_bwrap_missing: - duration_ms = int((time.time() - start_time) * 1000) - return ExecutionResult( - success=False, - stdout="", - stderr="", - exit_code=1, - duration_ms=duration_ms, - error=( - "bubblewrap (bwrap) is required for execute_code but is not available. " - "Install bwrap in the runtime environment or enable " - "allow_unsafe_fallback_when_bwrap_missing for local development." - ), - ) - - host_command = self._build_host_command(language, script_path, work_path) - logger.warning( - "[Subprocess] bubblewrap missing; using local fallback without filesystem isolation" - ) - proc = await asyncio.create_subprocess_exec( - *host_command, - cwd=str(work_path), - **self._build_exec_kwargs(work_path, timeout, use_preexec=True), - ) - else: - proc = await asyncio.create_subprocess_exec( - *bwrap_command, - cwd=str(work_path), - **self._build_exec_kwargs(work_path, timeout), + duration_ms = int((time.time() - start_time) * 1000) + return ExecutionResult( + success=False, + stdout="", + stderr="", + exit_code=1, + duration_ms=duration_ms, + error=( + "bubblewrap (bwrap) is required for execute_code but is not available. " + "Install bwrap in the runtime environment and restart the backend." + ), ) + safe_env = self._build_safe_env(work_path) + + kwargs = { + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + "env": safe_env, + } + + proc = await asyncio.create_subprocess_exec( + *bwrap_command, + cwd=str(work_path), + **kwargs, + ) + try: stdout, stderr = await asyncio.wait_for( proc.communicate(), @@ -423,7 +399,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[Subprocess] Execution error") + logger.exception("[Subprocess] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/sandbox/registry.py b/backend/app/services/sandbox/registry.py index f6736e46b..f0dcac624 100644 --- a/backend/app/services/sandbox/registry.py +++ b/backend/app/services/sandbox/registry.py @@ -1,7 +1,6 @@ """Sandbox backend registry and factory.""" from typing import Type -from loguru import logger from app.services.sandbox.base import SandboxBackend from app.services.sandbox.config import SandboxConfig, SandboxType diff --git a/backend/app/services/sandbox/remote/aio_sandbox_backend.py b/backend/app/services/sandbox/remote/aio_sandbox_backend.py index 5445c4ed2..0ec2324ea 100644 --- a/backend/app/services/sandbox/remote/aio_sandbox_backend.py +++ b/backend/app/services/sandbox/remote/aio_sandbox_backend.py @@ -182,7 +182,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[AioSandbox] Execution error") + logger.exception("[AioSandbox] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/sandbox/remote/self_hosted_backend.py b/backend/app/services/sandbox/remote/self_hosted_backend.py index 8d38a2183..dbe9857bc 100644 --- a/backend/app/services/sandbox/remote/self_hosted_backend.py +++ b/backend/app/services/sandbox/remote/self_hosted_backend.py @@ -191,7 +191,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[SelfHosted] Execution error") + logger.exception("[SelfHosted] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 157c048af..619698a61 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -6,13 +6,12 @@ """ import asyncio -import json import uuid from datetime import datetime, timezone from croniter import croniter from loguru import logger -from sqlalchemy import select, update +from sqlalchemy import select def compute_next_run(cron_expr: str, after: datetime | None = None) -> datetime | None: @@ -94,7 +93,7 @@ async def _tick(): async with async_session() as db: result = await db.execute( select(AgentSchedule).where( - AgentSchedule.is_enabled == True, + AgentSchedule.is_enabled, AgentSchedule.next_run_at <= now, ) ) diff --git a/backend/app/services/session_title.py b/backend/app/services/session_title.py new file mode 100644 index 000000000..6942197d2 --- /dev/null +++ b/backend/app/services/session_title.py @@ -0,0 +1,76 @@ +"""Generate session titles using the tenant utility LLM model.""" + +import logging +from uuid import UUID + +from sqlalchemy import select + +from app.database import async_session +from app.models.chat_session import ChatSession +from app.services.llm import get_model_api_key +from app.services.llm.client import chat_complete + +logger = logging.getLogger(__name__) + +TITLE_SYSTEM_PROMPT = ( + "Generate a concise title (max 8 words) for this conversation. " + "Return only the title text, nothing else. No quotes, no punctuation at the end." +) + + +async def generate_session_title( + session_id: str, + user_message: str, + assistant_response: str, + utility_model, + websocket=None, +) -> str | None: + """Generate a session title and persist it unless the user already edited it.""" + try: + messages = [ + {"role": "system", "content": TITLE_SYSTEM_PROMPT}, + {"role": "user", "content": user_message[:500]}, + {"role": "assistant", "content": assistant_response[:500]}, + {"role": "user", "content": "Based on this conversation, generate a title."}, + ] + + response = await chat_complete( + provider=utility_model.provider, + api_key=get_model_api_key(utility_model), + model=utility_model.model, + messages=messages, + base_url=utility_model.base_url, + timeout=float(getattr(utility_model, "request_timeout", None) or 120.0), + ) + + raw = response.get("choices", [{}])[0].get("message", {}).get("content", "") + title = raw.strip().strip('"').strip("'")[:80] + if not title: + return None + + async with async_session() as db: + result = await db.execute(select(ChatSession).where(ChatSession.id == UUID(session_id))) + session = result.scalar_one_or_none() + if not session or session.title_edited: + return None + + session.title = title + await db.commit() + logger.info("[SessionTitle] Generated title for %s: %s", session_id, title) + + if websocket: + try: + await websocket.send_json( + { + "type": "session_title_updated", + "session_id": session_id, + "title": title, + } + ) + except Exception: + pass + + return title + except Exception as exc: + logger.warning("[SessionTitle] Failed to generate title for %s: %s", session_id, exc) + return None diff --git a/backend/app/services/skill_archive_import.py b/backend/app/services/skill_archive_import.py new file mode 100644 index 000000000..fa159e337 --- /dev/null +++ b/backend/app/services/skill_archive_import.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile +from pathlib import Path + +from fastapi import HTTPException + +MAX_SKILL_ARCHIVE_SIZE = 50 * 1024 * 1024 +MAX_SKILL_ARCHIVE_FILES = 1000 +MAX_SKILL_UNCOMPRESSED = 500 * 1024 * 1024 + + +def _normalize_member_path(member: str) -> str: + if not member: + raise HTTPException(status_code=400, detail="Invalid archive path") + + normalized_member = member.replace("\\", "/") + path = Path(normalized_member) + has_windows_drive = len(normalized_member) >= 3 and normalized_member[0].isalpha() and normalized_member[1:3] == ":/" + has_unc_prefix = normalized_member.startswith("//") + if normalized_member.startswith("/") or has_windows_drive or has_unc_prefix or path.is_absolute() or ".." in path.parts: + raise HTTPException(status_code=400, detail="Invalid archive path") + + return str(path).replace("\\", "/") + + +def _shared_root_folder(paths: list[str]) -> str: + if not paths or any("/" not in path for path in paths): + return "" + + roots = {path.split("/", 1)[0] for path in paths} + return roots.pop() if len(roots) == 1 else "" + + +def inspect_skill_archive(data: bytes, *, target_folder: str) -> dict: + if len(data) > MAX_SKILL_ARCHIVE_SIZE: + raise HTTPException(status_code=400, detail="Skill archive too large") + + try: + zf = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile as exc: + raise HTTPException(status_code=400, detail="Invalid skill archive") from exc + + total_uncompressed = sum(item.file_size for item in zf.infolist()) + if total_uncompressed > MAX_SKILL_UNCOMPRESSED: + raise HTTPException(status_code=400, detail="Skill archive uncompressed size too large") + + files: dict[str, str] = {} + with zf: + members = [item for item in zf.infolist() if not item.is_dir()] + if len(members) > MAX_SKILL_ARCHIVE_FILES: + raise HTTPException(status_code=400, detail="Too many files in skill archive") + + raw_paths = [_normalize_member_path(item.filename) for item in members] + strip_root = _shared_root_folder(raw_paths) + + for item, raw_path in zip(members, raw_paths, strict=False): + rel_path = raw_path + if strip_root: + rel_path = rel_path[len(strip_root) + 1 :] + rel_path = rel_path.strip("/") + if not rel_path: + continue + raw_content = zf.read(item) + try: + files[rel_path] = raw_content.decode("utf-8") + except UnicodeDecodeError as exc: + raise HTTPException( + status_code=400, + detail=f"Skill archive file '{rel_path}' must be UTF-8 text", + ) from exc + + if "SKILL.md" not in files: + raise HTTPException(status_code=400, detail="Uploaded folder must contain a root SKILL.md") + + digest = hashlib.sha256() + for path in sorted(files): + digest.update(path.encode("utf-8")) + digest.update(b"\0") + digest.update(files[path].encode("utf-8")) + digest.update(b"\0") + + diff = diff_skill_manifests(files, {}) + return { + "target_folder": target_folder, + "files": files, + "total_files": len(files), + "digest": digest.hexdigest(), + "diff": diff, + } + + +def diff_skill_manifests(uploaded: dict[str, str], existing: dict[str, str]) -> dict[str, list[str]]: + uploaded_paths = set(uploaded) + existing_paths = set(existing) + added = sorted(uploaded_paths - existing_paths) + deleted = sorted(existing_paths - uploaded_paths) + changed = sorted(path for path in uploaded_paths & existing_paths if uploaded[path] != existing[path]) + return {"added": added, "changed": changed, "deleted": deleted} diff --git a/backend/app/services/skill_creator_content.py b/backend/app/services/skill_creator_content.py index f42610b95..af23cfad9 100644 --- a/backend/app/services/skill_creator_content.py +++ b/backend/app/services/skill_creator_content.py @@ -6,7 +6,6 @@ to keep the seeder clean and avoid triple-quote nesting issues. """ -import os from pathlib import Path _DIR = Path(__file__).parent / "skill_creator_files" diff --git a/backend/app/services/skill_creator_files/eval-viewer__generate_review.py b/backend/app/services/skill_creator_files/eval-viewer__generate_review.py index 4f0b1fe00..04b4271dd 100644 --- a/backend/app/services/skill_creator_files/eval-viewer__generate_review.py +++ b/backend/app/services/skill_creator_files/eval-viewer__generate_review.py @@ -449,8 +449,8 @@ def main() -> None: port = server.server_address[1] url = f"http://localhost:{port}" - logger.info(f"\n Eval Viewer") - logger.info(f" ─────────────────────────────────") + logger.info("\n Eval Viewer") + logger.info(" ─────────────────────────────────") logger.info(f" URL: {url}") logger.info(f" Workspace: {workspace}") logger.info(f" Feedback: {feedback_path}") @@ -458,7 +458,7 @@ def main() -> None: logger.info(f" Previous: {args.previous_workspace} ({len(previous)} runs)") if benchmark_path: logger.info(f" Benchmark: {benchmark_path}") - logger.info(f"\n Press Ctrl+C to stop.\n") + logger.info("\n Press Ctrl+C to stop.\n") webbrowser.open(url) diff --git a/backend/app/services/skill_creator_files/scripts__aggregate_benchmark.py b/backend/app/services/skill_creator_files/scripts__aggregate_benchmark.py index ccc810819..bedaf2b73 100644 --- a/backend/app/services/skill_creator_files/scripts__aggregate_benchmark.py +++ b/backend/app/services/skill_creator_files/scripts__aggregate_benchmark.py @@ -391,7 +391,7 @@ def main(): configs = [k for k in run_summary if k != "delta"] delta = run_summary.get("delta", {}) - logger.info(f"\nSummary:") + logger.info("\nSummary:") for config in configs: pr = run_summary[config]["pass_rate"]["mean"] label = config.replace("_", " ").title() diff --git a/backend/app/services/skill_creator_files/scripts__generate_report.py b/backend/app/services/skill_creator_files/scripts__generate_report.py index 395232d96..82cc63250 100644 --- a/backend/app/services/skill_creator_files/scripts__generate_report.py +++ b/backend/app/services/skill_creator_files/scripts__generate_report.py @@ -18,7 +18,7 @@ def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str: """Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag.""" history = data.get("history", []) - holdout = data.get("holdout", 0) + data.get("holdout", 0) title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else "" # Get all unique queries from train and test sets, with should_trigger info @@ -156,7 +156,7 @@ def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") # Summary section best_test_score = data.get('best_test_score') - best_train_score = data.get('best_train_score') + data.get('best_train_score') html_parts.append(f"""

Original: {html.escape(data.get('original_description', 'N/A'))}

@@ -213,10 +213,10 @@ def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") # Add rows for each iteration for h in history: iteration = h.get("iteration", "?") - train_passed = h.get("train_passed", h.get("passed", 0)) - train_total = h.get("train_total", h.get("total", 0)) - test_passed = h.get("test_passed") - test_total = h.get("test_total") + h.get("train_passed", h.get("passed", 0)) + h.get("train_total", h.get("total", 0)) + h.get("test_passed") + h.get("test_total") description = h.get("description", "") train_results = h.get("train_results", h.get("results", [])) test_results = h.get("test_results", []) diff --git a/backend/app/services/skill_creator_files/scripts__quick_validate.py b/backend/app/services/skill_creator_files/scripts__quick_validate.py index 36553161e..2fd796681 100644 --- a/backend/app/services/skill_creator_files/scripts__quick_validate.py +++ b/backend/app/services/skill_creator_files/scripts__quick_validate.py @@ -4,7 +4,6 @@ """ import sys -import os import re import yaml from pathlib import Path diff --git a/backend/app/services/skill_creator_files/scripts__run_loop.py b/backend/app/services/skill_creator_files/scripts__run_loop.py index a2907d6e0..768129021 100644 --- a/backend/app/services/skill_creator_files/scripts__run_loop.py +++ b/backend/app/services/skill_creator_files/scripts__run_loop.py @@ -192,7 +192,7 @@ def print_eval_stats(label, results, elapsed): # Improve the description based on train results if verbose: - logger.info(f"\nImproving description...") + logger.info("\nImproving description...") t0 = time.time() # Strip test scores from history so improvement model can't see them diff --git a/backend/app/services/skill_map.py b/backend/app/services/skill_map.py new file mode 100644 index 000000000..64375f580 --- /dev/null +++ b/backend/app/services/skill_map.py @@ -0,0 +1,129 @@ +"""Build a flat, colon-keyed skill map by recursively scanning skills/**/*.md. + +Each .md file with a `name` field in YAML frontmatter is a skill entry. +The colon key is derived from the folder path + slugified name. +""" +import logging +import re +import time +from pathlib import Path +from typing import Any +from uuid import UUID + +logger = logging.getLogger(__name__) + +_cache: dict[str, tuple[float, dict]] = {} +_CACHE_TTL = 60 + + +def slugify(name: str) -> str: + """Convert 'Frontend Developer' -> 'frontend-developer'.""" + s = name.lower().strip() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def parse_frontmatter(content: str) -> dict[str, str]: + """Extract name, description, emoji from YAML frontmatter. + + Returns dict with keys present in frontmatter. Missing keys are omitted. + """ + stripped = content.strip() + if not stripped.startswith("---"): + return {} + end = stripped.find("---", 3) + if end == -1: + return {} + result = {} + for line in stripped[3:end].strip().split("\n"): + line = line.strip() + for field in ("name", "description", "emoji"): + if line.lower().startswith(f"{field}:"): + val = line[len(field) + 1:].strip().strip('"').strip("'") + if val: + result[field] = val if field != "description" else val[:200] + break + return result + + +def _build_colon_key(rel_path: Path, slugified_name: str) -> str: + """Build colon key from folder segments + slugified name, with dedup.""" + segments = list(rel_path.parent.parts) # folder segments, exclude filename + if segments and segments[-1] == slugified_name: + pass # dedup: last folder == slug, don't append + else: + segments.append(slugified_name) + return ":".join(segments) + + +def _scan_skills_dir(skills_dir: Path) -> dict[str, dict[str, str]]: + """Scan a skills directory recursively, return flat colon-keyed map.""" + entries: dict[str, dict[str, str]] = {} + + if not skills_dir.exists(): + return entries + + for md_file in sorted(skills_dir.rglob("*.md")): + if md_file.name.startswith("."): + continue + + try: + with open(md_file, "r", encoding="utf-8", errors="replace") as f: + head = f.read(1024) + except Exception: + continue + + fm = parse_frontmatter(head) + name = fm.get("name") + if not name: + continue + + slug = slugify(name) + rel = md_file.relative_to(skills_dir) + key = _build_colon_key(rel, slug) + + if key in entries: + logger.warning(f"Skill key collision '{key}': keeping '{entries[key]['file']}', skipping '{rel}'") + continue + + entries[key] = { + "name": name, + "description": fm.get("description", ""), + "emoji": fm.get("emoji", ""), + "file": str(rel), + } + + return entries + + +def get_skill_map(agent_id: UUID) -> dict[str, Any]: + """Build flat colon-keyed skill map for an agent. Cached with 60s TTL.""" + cache_key = str(agent_id) + now = time.time() + + if cache_key in _cache: + ts, result = _cache[cache_key] + if now - ts < _CACHE_TTL: + return result + + from app.services.agent_context import _agent_workspace + + skills_dir = _agent_workspace(agent_id) / "skills" + result = _scan_skills_dir(skills_dir) + + _cache[cache_key] = (now, result) + return result + + +def get_skill_map_for_api(agent_id: UUID) -> dict[str, Any]: + """Return skill map without file paths (safe for API response).""" + full = get_skill_map(agent_id) + return { + key: {k: v for k, v in entry.items() if k != "file"} + for key, entry in full.items() + } + + +def invalidate_cache(agent_id: UUID) -> None: + """Remove cached skill map for an agent.""" + _cache.pop(str(agent_id), None) diff --git a/backend/app/services/skill_seeder.py b/backend/app/services/skill_seeder.py index b3c1b2612..2955c181b 100644 --- a/backend/app/services/skill_seeder.py +++ b/backend/app/services/skill_seeder.py @@ -930,16 +930,15 @@ async def push_default_skills_to_existing_agents(): Called at startup after seed_skills() so existing agents automatically receive new default skills like mcp-installer without requiring manual re-creation. """ - from pathlib import Path from app.models.agent import Agent - from app.models.skill import Skill, SkillFile + from app.models.skill import Skill from sqlalchemy.orm import selectinload from app.services.agent_manager import agent_manager async with async_session() as db: # Load all is_default skills with their files default_skills_r = await db.execute( - select(Skill).where(Skill.is_default == True).options(selectinload(Skill.files)) + select(Skill).where(Skill.is_default).options(selectinload(Skill.files)) ) default_skills = default_skills_r.scalars().all() if not default_skills: diff --git a/backend/app/services/sso_service.py b/backend/app/services/sso_service.py index 324f2f357..74e8a5ba4 100644 --- a/backend/app/services/sso_service.py +++ b/backend/app/services/sso_service.py @@ -524,8 +524,8 @@ async def validate_sso_enablement(self, db: AsyncSession, tenant_id: uuid.UUID) # IP Address: only ONE tenant in the whole system can have SSO enabled. # Check if any *other* tenant has an active SSO-enabled provider. query = select(IdentityProvider).where( - IdentityProvider.sso_login_enabled == True, - IdentityProvider.is_active == True, + IdentityProvider.sso_login_enabled, + IdentityProvider.is_active, IdentityProvider.tenant_id != tenant_id, ) result = await db.execute(query) diff --git a/backend/app/services/supervision_reminder.py b/backend/app/services/supervision_reminder.py index 809285c7f..611e79b31 100644 --- a/backend/app/services/supervision_reminder.py +++ b/backend/app/services/supervision_reminder.py @@ -108,6 +108,7 @@ async def _get_agent_reply(target_agent, message: str, db) -> str | None: from app.services.llm import ( get_provider_base_url, create_llm_client, + LLMError, LLMMessage, get_model_api_key, ) @@ -185,7 +186,7 @@ async def _send_supervision_reminder(task: Task, agent_name: str): reminder_msg += f"创建于:{days_since} 天前\n" if task.due_date: reminder_msg += f"截止日期:{task.due_date.strftime('%Y-%m-%d')}\n" - reminder_msg += f"\n请及时处理,谢谢!" + reminder_msg += "\n请及时处理,谢谢!" async with async_session() as db: sent = False diff --git a/backend/app/services/system_email_service.py b/backend/app/services/system_email_service.py index db50d5fcd..46a702026 100644 --- a/backend/app/services/system_email_service.py +++ b/backend/app/services/system_email_service.py @@ -8,11 +8,9 @@ from __future__ import annotations import asyncio -import inspect import logging import smtplib import ssl -import uuid from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime @@ -20,7 +18,7 @@ from email.mime.text import MIMEText from email.utils import formataddr, make_msgid -from app.core.email import force_ipv4, send_smtp_email +from app.core.email import force_ipv4 logger = logging.getLogger(__name__) @@ -116,17 +114,27 @@ def _send_email_with_config_sync(config: SystemEmailConfig, to: str, subject: st msg["Date"] = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") msg.attach(MIMEText(body, "plain", "utf-8")) - send_smtp_email( - host=config.smtp_host, - port=config.smtp_port, - user=config.smtp_username, - password=config.smtp_password, - from_addr=config.from_address, - to_addrs=[to], - msg_string=msg.as_string(), - use_ssl=config.smtp_ssl, - timeout=config.smtp_timeout_seconds, - ) + timeout = config.smtp_timeout_seconds + with force_ipv4(): + if config.smtp_ssl: + context = ssl.create_default_context() + with smtplib.SMTP_SSL( + config.smtp_host, + config.smtp_port, + context=context, + timeout=timeout, + ) as smtp: + if config.smtp_username: + smtp.login(config.smtp_username, config.smtp_password) + smtp.sendmail(config.from_address, [to], msg.as_string()) + else: + with smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=timeout) as smtp: + smtp.ehlo() + smtp.starttls(context=ssl.create_default_context()) + smtp.ehlo() + if config.smtp_username: + smtp.login(config.smtp_username, config.smtp_password) + smtp.sendmail(config.from_address, [to], msg.as_string()) async def send_password_reset_email( @@ -188,6 +196,11 @@ async def deliver_broadcast_emails(recipients: Iterable[BroadcastEmailRecipient] logger.warning("Failed to deliver broadcast email to %s: %s", recipient.email, exc) +async def run_background_email_job(fn, *args, **kwargs) -> None: + """Small async wrapper for background email tasks.""" + await fn(*args, **kwargs) + + # ── Email Templates ────────────────────────────────────────────────────────── # Default templates for each email scenario. @@ -239,8 +252,6 @@ async def get_email_templates(db=None) -> dict[str, dict[str, str]]: Returns: A dict mapping scenario_key -> {"subject": str, "body": str} """ - from sqlalchemy import select - from app.models.system_settings import SystemSetting templates = dict(DEFAULT_EMAIL_TEMPLATES) # start with defaults diff --git a/backend/app/services/task_executor.py b/backend/app/services/task_executor.py index 33f394581..166a999dd 100644 --- a/backend/app/services/task_executor.py +++ b/backend/app/services/task_executor.py @@ -4,8 +4,6 @@ as the chat dialog. Supports tool-calling loop for autonomous execution. """ -import asyncio -import json import uuid from datetime import datetime, timezone @@ -15,7 +13,6 @@ from app.config import get_settings from app.database import async_session from app.models.agent import Agent -from app.models.llm import LLMModel from app.models.task import Task, TaskLog settings = get_settings() diff --git a/backend/app/services/template_seeder.py b/backend/app/services/template_seeder.py index caccdb515..83f1fda45 100644 --- a/backend/app/services/template_seeder.py +++ b/backend/app/services/template_seeder.py @@ -455,7 +455,7 @@ async def seed_agent_templates(): current_names = {t["name"] for t in templates} result = await db.execute( - select(AgentTemplate).where(AgentTemplate.is_builtin == True) + select(AgentTemplate).where(AgentTemplate.is_builtin) ) existing_builtins = result.scalars().all() for old in existing_builtins: @@ -475,7 +475,7 @@ async def seed_agent_templates(): result = await db.execute( select(AgentTemplate).where( AgentTemplate.name == tmpl["name"], - AgentTemplate.is_builtin == True, + AgentTemplate.is_builtin, ) ) existing = result.scalar_one_or_none() diff --git a/backend/app/services/timezone_utils.py b/backend/app/services/timezone_utils.py index 8a2696393..07cb43308 100644 --- a/backend/app/services/timezone_utils.py +++ b/backend/app/services/timezone_utils.py @@ -2,7 +2,7 @@ import uuid from zoneinfo import ZoneInfo -from datetime import datetime, timezone +from datetime import datetime from sqlalchemy import select diff --git a/backend/app/services/tool_seeder.py b/backend/app/services/tool_seeder.py index 909d72949..f190c61e6 100644 --- a/backend/app/services/tool_seeder.py +++ b/backend/app/services/tool_seeder.py @@ -3191,9 +3191,182 @@ def _global_builtin_config(tool_data: dict) -> dict: }, ] +WORKSPACE_BUILTIN_TOOLS = [ + { + "name": "request_build", + "display_name": "Request Build", + "description": "Create a build request for the Software Engineer agent. Any agent or human can use this to request a new website, tool, or application to be built and deployed.", + "category": "workspace", + "icon": "🔨", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "URL-friendly project identifier (lowercase, hyphens allowed, 2-50 chars). This becomes the URL path: /workspace/{slug}/", + }, + "name": {"type": "string", "description": "Human-readable project name"}, + "description": { + "type": "string", + "description": "Detailed description of what to build, including requirements, target audience, and any design preferences", + }, + }, + "required": ["slug", "name", "description"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "list_build_requests", + "display_name": "List Build Requests", + "description": "List pending build requests (status=requested) for the Software Engineer agent to pick up.", + "category": "workspace", + "icon": "📋", + "is_default": True, + "parameters_schema": {"type": "object", "properties": {}}, + "config": {}, + "config_schema": {}, + }, + { + "name": "deploy_static", + "display_name": "Deploy Static Site", + "description": "Deploy a static website (HTML/CSS/JS) from your workspace to the public workspace. Goes live immediately without approval. The files will be served at /workspace/{slug}/.", + "category": "workspace", + "icon": "🚀", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": {"type": "string", "description": "Project slug (must match an existing build request or be a new unique slug)"}, + "source_dir": { + "type": "string", + "description": "Directory in your workspace containing the built files (e.g., 'workspace/my-project'). Must contain at least an index.html.", + }, + }, + "required": ["slug", "source_dir"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "request_container_deploy", + "display_name": "Request Container Deploy", + "description": "Submit a container-based application for deployment. Requires Frank's approval before going live. The application will be built from a Dockerfile in your workspace.", + "category": "workspace", + "icon": "📦", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": {"type": "string", "description": "Project slug for the URL path /workspace/{slug}/"}, + "dockerfile_path": {"type": "string", "description": "Path to the Dockerfile in your workspace (e.g., 'workspace/my-app/Dockerfile')"}, + "port": {"type": "integer", "description": "Port the application listens on inside the container"}, + "name": {"type": "string", "description": "Human-readable project name"}, + "description": {"type": "string", "description": "What this application does"}, + "resource_limits_suggestion": { + "type": "object", + "description": "Suggested resource limits (optional). Frank will set final limits at approval.", + "properties": { + "memory": {"type": "string", "description": "e.g., '256m', '512m'"}, + "cpus": {"type": "string", "description": "e.g., '0.5', '1'"}, + }, + }, + }, + "required": ["slug", "dockerfile_path", "port", "name", "description"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "list_workspace_projects", + "display_name": "List Workspace Projects", + "description": "List all workspace projects with their current status, deploy type, and URLs.", + "category": "workspace", + "icon": "📋", + "is_default": True, + "parameters_schema": {"type": "object", "properties": {}}, + "config": {}, + "config_schema": {}, + }, + { + "name": "undeploy_project", + "display_name": "Undeploy Project", + "description": "Remove a deployed workspace project. For static sites, deletes files. For containers, stops and removes the container.", + "category": "workspace", + "icon": "🗑️", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": {"slug": {"type": "string", "description": "The project slug to undeploy"}}, + "required": ["slug"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "get_bug_reports", + "display_name": "Get Bug Reports", + "description": "List open bug reports for workspace projects. Use this to find issues that need fixing.", + "category": "workspace", + "icon": "🐛", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "status_filter": { + "type": "string", + "description": "Filter by status: 'open', 'investigating', 'escalated', or 'all'. Default: 'open'", + }, + }, + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "resolve_bug", + "display_name": "Resolve Bug", + "description": "Mark a bug report as fixed after you have redeployed the fix.", + "category": "workspace", + "icon": "✅", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "bug_report_id": {"type": "string", "description": "The UUID of the bug report to resolve"}, + }, + "required": ["bug_report_id"], + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "report_workspace_bug", + "display_name": "Report Workspace Bug", + "description": "Report a bug on a deployed workspace project. Creates a bug report for the Software Engineer agent to investigate and fix.", + "category": "workspace", + "icon": "🐞", + "is_default": True, + "parameters_schema": { + "type": "object", + "properties": { + "slug": {"type": "string", "description": "The project slug with the bug"}, + "description": { + "type": "string", + "description": "Detailed description of the bug, including steps to reproduce if possible", + }, + }, + "required": ["slug", "description"], + }, + "config": {}, + "config_schema": {}, + }, +] + BUILTIN_TOOLS = [ *BUILTIN_TOOLS, *OKR_BUILTIN_TOOLS, + *WORKSPACE_BUILTIN_TOOLS, ] @@ -3516,7 +3689,7 @@ async def clean_orphaned_mcp_tools(): stmt = delete(Tool).where( and_( Tool.type == "mcp", - Tool.tenant_id == None, + Tool.tenant_id is None, ~Tool.id.in_(assigned_ids) if assigned_ids else True ) ) diff --git a/backend/app/services/trigger_daemon.py b/backend/app/services/trigger_daemon.py index 0e9e3df10..c5de544b4 100644 --- a/backend/app/services/trigger_daemon.py +++ b/backend/app/services/trigger_daemon.py @@ -466,7 +466,7 @@ async def _check_new_agent_messages(trigger: AgentTrigger) -> bool: # Look up user by display name or username within tenant from sqlalchemy import or_ - from app.models.user import User, Identity + from app.models.user import Identity safe_user_name = from_user_name.replace("%", "").replace("_", r"\_") query = ( select(User) @@ -614,7 +614,6 @@ async def _invoke_agent_for_triggers(agent_id: uuid.UUID, triggers: list[AgentTr Creates a Reflection Session and calls the LLM. """ from app.services.llm import call_llm - from app.services.agent_context import build_agent_context from app.models.llm import LLMModel from app.models.audit import ChatMessage from app.models.chat_session import ChatSession @@ -971,7 +970,7 @@ async def _tick(): async with async_session() as db: result = await db.execute( - select(AgentTrigger).where(AgentTrigger.is_enabled == True) + select(AgentTrigger).where(AgentTrigger.is_enabled) ) all_triggers = result.scalars().all() @@ -1058,7 +1057,6 @@ async def wake_agent_with_context(agent_id: uuid.UUID, message_context: str, *, skip_dedup: If True, bypass the dedup window check. a2a_session_id: Optional A2A chat session ID to mirror the reply into. """ - import time as _time now = datetime.now(timezone.utc) diff --git a/backend/app/services/wechat_channel.py b/backend/app/services/wechat_channel.py index 8ad24fe26..bf571df78 100644 --- a/backend/app/services/wechat_channel.py +++ b/backend/app/services/wechat_channel.py @@ -331,7 +331,7 @@ async def start_all(self) -> None: result = await db.execute( select(ChannelConfig).where( ChannelConfig.channel_type == "wechat", - ChannelConfig.is_configured == True, + ChannelConfig.is_configured, ) ) for cfg in result.scalars().all(): diff --git a/backend/app/services/workspace_collaboration.py b/backend/app/services/workspace_collaboration.py index e7a8be898..5b2ebfacc 100644 --- a/backend/app/services/workspace_collaboration.py +++ b/backend/app/services/workspace_collaboration.py @@ -14,7 +14,7 @@ from pathlib import Path import aiofiles -from sqlalchemy import and_, delete, desc, select +from sqlalchemy import delete, desc, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.workspace import WorkspaceEditLock, WorkspaceFileRevision diff --git a/backend/app/services/workspace_health.py b/backend/app/services/workspace_health.py new file mode 100644 index 000000000..127542013 --- /dev/null +++ b/backend/app/services/workspace_health.py @@ -0,0 +1,147 @@ +"""Periodic health checks for deployed workspace projects.""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +import httpx +from sqlalchemy import select + +from app.config import get_settings +from app.database import async_session +from app.models.workspace import WorkspaceBugReport, WorkspaceProject + +logger = logging.getLogger(__name__) + +settings = get_settings() + +CHECK_INTERVAL = 300 # 5 minutes +RETRY_DELAY = 30 # seconds +GRACE_PERIOD = 30 # seconds after deploy before first check +MAX_AUTO_FIX = 3 +AUTO_FIX_WINDOW = timedelta(hours=24) + + +async def run_health_checks(): + """Background loop that checks workspace project health every 5 minutes.""" + logger.info("Workspace health check task started") + while True: + try: + await _check_all_projects() + except Exception: + logger.exception("Health check cycle failed") + await asyncio.sleep(CHECK_INTERVAL) + + +async def _check_all_projects(): + """Check all deployed projects.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.status == "deployed") + ) + projects = result.scalars().all() + + now = datetime.now(timezone.utc) + eligible = [ + p for p in projects + if not p.updated_at or (now - p.updated_at.replace(tzinfo=timezone.utc)).total_seconds() >= GRACE_PERIOD + ] + + # Check all projects in parallel + results = await asyncio.gather( + *[_check_project(p) for p in eligible], + return_exceptions=True, + ) + + # Retry failed ones (in parallel), then create bug reports + failed = [p for p, r in zip(eligible, results) if r is not True] + if failed: + await asyncio.sleep(RETRY_DELAY) + retry_results = await asyncio.gather( + *[_check_project(p) for p in failed], + return_exceptions=True, + ) + for p, r in zip(failed, retry_results): + if r is not True: + await _create_health_bug_report(p) + + +async def _check_project(project: WorkspaceProject) -> bool: + """Check a single project's health. Returns True if healthy.""" + if project.deploy_type == "static": + url = f"http://{settings.WORKSPACE_GATEWAY_CONTAINER}/workspace/{project.slug}/" + method = "HEAD" + else: + endpoint = project.health_endpoint or f"/workspace/{project.slug}/health" + url = f"http://{settings.WORKSPACE_GATEWAY_CONTAINER}{endpoint}" + method = "GET" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + if method == "HEAD": + resp = await client.head(url) + else: + resp = await client.get(url) + return resp.status_code == 200 + except Exception as e: + logger.warning("Health check failed for %s: %s", project.slug, e) + return False + + +async def _create_health_bug_report(project: WorkspaceProject): + """Create a bug report for a failed health check, respecting circuit breaker.""" + now = datetime.now(timezone.utc) + + async with async_session() as db: + # Re-fetch to get latest state + proj = await db.get(WorkspaceProject, project.id) + if not proj or proj.status != "deployed": + return + + # Check circuit breaker + if proj.auto_fix_window_start: + window_start = proj.auto_fix_window_start.replace(tzinfo=timezone.utc) + if now - window_start < AUTO_FIX_WINDOW: + if proj.auto_fix_attempts >= MAX_AUTO_FIX: + # Exceeded max attempts — escalate + proj.status = "failed" + report = WorkspaceBugReport( + project_id=proj.id, + source="health_check", + description=f"Health check failed. Auto-fix limit ({MAX_AUTO_FIX}) exceeded in 24h window. Manual intervention required.", + status="escalated", + ) + db.add(report) + await db.commit() + logger.warning("Project '%s' escalated — auto-fix limit exceeded", proj.slug) + return + else: + # Window expired, reset + proj.auto_fix_attempts = 0 + proj.auto_fix_window_start = now + + if not proj.auto_fix_window_start: + proj.auto_fix_window_start = now + + proj.auto_fix_attempts += 1 + + # Check if there's already an open health_check report for this project + existing = await db.execute( + select(WorkspaceBugReport).where( + WorkspaceBugReport.project_id == proj.id, + WorkspaceBugReport.source == "health_check", + WorkspaceBugReport.status.in_(["open", "investigating"]), + ) + ) + if existing.scalar_one_or_none(): + await db.commit() # save the attempt counter + return # don't duplicate + + report = WorkspaceBugReport( + project_id=proj.id, + source="health_check", + description=f"Health check failed for /workspace/{proj.slug}/. The site may be down or returning errors.", + ) + db.add(report) + await db.commit() + logger.info("Health check bug report created for '%s' (attempt %d/%d)", proj.slug, proj.auto_fix_attempts, MAX_AUTO_FIX) diff --git a/backend/app/services/workspace_index.py b/backend/app/services/workspace_index.py new file mode 100644 index 000000000..45569f355 --- /dev/null +++ b/backend/app/services/workspace_index.py @@ -0,0 +1,164 @@ +"""Generates the workspace index page at /workspace/.""" + +import html +import logging +from pathlib import Path + +from sqlalchemy import select + +from app.config import get_settings +from app.database import async_session +from app.models.workspace import WorkspaceProject + +logger = logging.getLogger(__name__) + +settings = get_settings() + +STATUS_COLORS = { + "deployed": "#22c55e", + "building": "#f59e0b", + "awaiting_approval": "#3b82f6", + "failed": "#ef4444", + "stopped": "#6b7280", +} + + +async def regenerate_index() -> None: + """Regenerate the workspace index HTML. Non-blocking — logs errors but does not raise.""" + try: + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject) + .where(WorkspaceProject.status == "deployed") + .order_by(WorkspaceProject.created_at.desc()) + ) + projects = result.scalars().all() + + index_dir = Path(settings.WORKSPACE_STATIC_DIR) / "_index" + index_dir.mkdir(parents=True, exist_ok=True) + + project_cards = "" + for p in projects: + badge_color = STATUS_COLORS.get(p.status, "#6b7280") + project_cards += f""" +
+
+ {html.escape(p.name)} + {p.status} +
+

{html.escape(p.description or "No description")}

+ +
""" + + empty_msg = '

No projects deployed yet.

' if not projects else "" + + page_html = f""" + + + + + Workspace — NLearn Consultant Company + + + +

NLearn Consultant Company — Workspace

+

Projects built by our AI team

+
+ {empty_msg} + {project_cards} + + + + + + +""" + + (index_dir / "index.html").write_text(page_html, encoding="utf-8") + logger.info("Workspace index page regenerated with %d projects", len(projects)) + + except Exception: + logger.exception("Failed to regenerate workspace index page") + + + # Note: uses stdlib html.escape() imported at top of file diff --git a/backend/app/services/workspace_tools.py b/backend/app/services/workspace_tools.py new file mode 100644 index 000000000..46db4d302 --- /dev/null +++ b/backend/app/services/workspace_tools.py @@ -0,0 +1,742 @@ +"""Workspace deployment tools for the Software Engineer agent.""" + +import json +import logging +import re +import shutil +import uuid +from pathlib import Path + +from sqlalchemy import or_, select +from sqlalchemy.exc import IntegrityError + +from app.config import get_settings +from app.database import async_session +from app.models.agent import Agent as AgentModel +from app.models.workspace import WorkspaceBugReport, WorkspaceProject +from app.services.workspace_index import regenerate_index + +logger = logging.getLogger(__name__) + +SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$") +RESERVED_SLUGS = {"_index", "api", "static", "health"} + +settings = get_settings() + + +async def _get_agent_tenant_id(agent_id: uuid.UUID) -> uuid.UUID | None: + """Resolve tenant ownership for an agent-driven workspace action.""" + async with async_session() as db: + result = await db.execute(select(AgentModel.tenant_id).where(AgentModel.id == agent_id)) + return result.scalar_one_or_none() + + +def _scope_workspace_project_query(query, tenant_id: uuid.UUID | None, include_platform_global: bool): + """Apply tenant scope to workspace admin queries.""" + if tenant_id is None: + return query + if include_platform_global: + return query.where(or_(WorkspaceProject.tenant_id == tenant_id, WorkspaceProject.tenant_id.is_(None))) + return query.where(WorkspaceProject.tenant_id == tenant_id) + + +def validate_slug(slug: str) -> str | None: + """Validate a workspace slug. Returns error message or None if valid.""" + if not slug: + return "Slug cannot be empty." + if slug in RESERVED_SLUGS: + return f"Slug '{slug}' is reserved. Choose a different name." + if not SLUG_PATTERN.match(slug): + return ( + f"Invalid slug '{slug}'. Must be 2-50 characters, " + "lowercase letters, numbers, and hyphens only. " + "Cannot start or end with a hyphen." + ) + return None + + +async def check_slug_available(slug: str) -> str | None: + """Check if slug is available in DB. Returns error message or None.""" + async with async_session() as db: + existing = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + if existing.scalar_one_or_none(): + return f"Slug '{slug}' is already in use." + return None + + +def _normalize_build_request(arguments: dict) -> tuple[str, str, str]: + """Extract normalized build-request fields from tool arguments.""" + return ( + arguments.get("slug", "").strip(), + arguments.get("name", "").strip(), + arguments.get("description", "").strip(), + ) + + +async def _validate_build_request(slug: str, name: str, description: str) -> str | None: + """Validate common build-request fields before writing the project row.""" + if not name or not description: + return "Error: 'name' and 'description' are required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + avail_error = await check_slug_available(slug) + if avail_error: + return f"Error: {avail_error}" + + return None + + +async def _create_build_request_record( + *, + slug: str, + name: str, + description: str, + tenant_id: uuid.UUID | None = None, + requested_by: uuid.UUID | None = None, + requested_by_human: str | None = None, +) -> str | None: + """Persist a pending build request and normalize duplicate-slug failures.""" + try: + async with async_session() as db: + project = WorkspaceProject( + slug=slug, + name=name, + description=description, + tenant_id=tenant_id, + requested_by=requested_by, + requested_by_human=requested_by_human, + status="requested", + ) + db.add(project) + await db.commit() + except IntegrityError: + return f"Error: Slug '{slug}' is already in use." + + return None + + +def _format_build_requester(project: WorkspaceProject) -> str: + """Render the human-friendly requester label for a pending build request.""" + return project.requested_by_human or f"Agent {project.requested_by}" + + +def _get_docker_client(): + """Get a Docker client connected via the mounted socket. Lazy import to avoid import-time failure.""" + import docker + return docker.DockerClient(base_url="unix:///var/run/docker.sock") + + +def _resolve_project_dockerfile(agent_ws: Path, slug: str, project: WorkspaceProject) -> Path: + """Resolve the Dockerfile for a pending workspace deployment. + + Priority: + 1. Stored project.dockerfile_path (deterministic) + 2. Backward-compatible fallback: exactly one Dockerfile under workspace/{slug}/ + """ + dockerfile_rel = (project.dockerfile_path or "").strip() + if dockerfile_rel: + candidate = (agent_ws / dockerfile_rel).resolve() + if not str(candidate).startswith(str(agent_ws.resolve())): + raise ValueError("Stored dockerfile_path escapes the agent workspace.") + if not candidate.exists() or not candidate.is_file(): + raise FileNotFoundError("Stored dockerfile_path does not exist.") + return candidate + + slug_candidates = list(agent_ws.glob(f"workspace/{slug}/**/Dockerfile")) + if len(slug_candidates) == 1: + return slug_candidates[0] + if len(slug_candidates) > 1: + raise ValueError( + f"Multiple Dockerfiles found for project '{slug}'. Please resubmit with an explicit dockerfile_path." + ) + raise FileNotFoundError("No Dockerfile found for the pending deployment request.") + + +async def tool_request_build( + agent_id: uuid.UUID, arguments: dict +) -> str: + """Create a build request for the SE agent.""" + slug, name, description = _normalize_build_request(arguments) + + validation_error = await _validate_build_request(slug, name, description) + if validation_error: + return validation_error + + tenant_id = await _get_agent_tenant_id(agent_id) + create_error = await _create_build_request_record( + slug=slug, + name=name, + description=description, + tenant_id=tenant_id, + requested_by=agent_id, + ) + if create_error: + return create_error + + return ( + f"Build request created!\n" + f"- Slug: {slug}\n" + f"- Name: {name}\n" + f"- Description: {description}\n" + f"The Software Engineer agent will pick this up." + ) + + +async def tool_request_build_human( + arguments: dict, +) -> str: + """Create a build request from a human (no agent_id).""" + slug, name, description = _normalize_build_request(arguments) + requester = arguments.get("requester", "").strip() or "Frank" + + validation_error = await _validate_build_request(slug, name, description) + if validation_error: + return validation_error + + create_error = await _create_build_request_record( + slug=slug, + name=name, + description=description, + requested_by_human=requester, + ) + if create_error: + return create_error + + return f"Build request '{name}' created for slug '{slug}'." + + +async def tool_list_build_requests() -> str: + """List pending build requests.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject) + .where(WorkspaceProject.status == "requested") + .order_by(WorkspaceProject.created_at.asc()) + ) + projects = result.scalars().all() + + if not projects: + return "No pending build requests." + + lines = ["Pending build requests:\n"] + for project in projects: + lines.append( + f"- [{project.slug}] {project.name}\n" + f" Requested by: {_format_build_requester(project)}\n" + f" Description: {project.description}\n" + ) + return "\n".join(lines) + + +async def tool_deploy_static( + agent_id: uuid.UUID, ws: Path, arguments: dict +) -> str: + """Deploy static files from agent workspace to /srv/workspace/{slug}/.""" + slug = arguments.get("slug", "").strip() + source_dir = arguments.get("source_dir", "").strip() + + if not slug or not source_dir: + return "Error: 'slug' and 'source_dir' are required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + tenant_id = await _get_agent_tenant_id(agent_id) + + # Resolve source path within agent workspace + source_path = (ws / source_dir).resolve() + if not str(source_path).startswith(str(ws.resolve())): + return "Error: source_dir must be within your workspace." + if not source_path.is_dir(): + return f"Error: Directory '{source_dir}' not found in your workspace." + if not (source_path / "index.html").exists(): + return f"Error: No index.html found in '{source_dir}'. Static sites must have an index.html." + + # Check/create project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + + if project and project.deploy_type == "container" and project.status == "deployed": + return f"Error: Slug '{slug}' is used by a running container deployment. Undeploy it first." + + if not project: + # Auto-create project record for direct deploys + project = WorkspaceProject( + slug=slug, + name=slug.replace("-", " ").title(), + description="Deployed via deploy_static", + tenant_id=tenant_id, + built_by=agent_id, + deploy_type="static", + status="building", + ) + db.add(project) + await db.flush() + else: + project.tenant_id = tenant_id + project.deploy_type = "static" + project.built_by = agent_id + project.status = "building" + + await db.commit() + project_id = project.id + + # Copy files to workspace static dir + dest_path = Path(settings.WORKSPACE_STATIC_DIR) / slug + try: + if dest_path.exists(): + shutil.rmtree(dest_path) + shutil.copytree(source_path, dest_path) + except Exception as e: + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.id == project_id) + ) + project = result.scalar_one() + project.status = "failed" + await db.commit() + return f"Error copying files: {e}" + + # Update status + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.id == project_id) + ) + project = result.scalar_one() + project.status = "deployed" + await db.commit() + + # Regenerate index (non-blocking) + try: + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after deploy_static") + + return ( + f"Static site deployed successfully!\n" + f"- URL: /workspace/{slug}/\n" + f"- Files copied from: {source_dir}\n" + f"The site is now live." + ) + + +async def tool_request_container_deploy( + agent_id: uuid.UUID, ws: Path, arguments: dict +) -> str: + """Submit a container deployment for approval.""" + slug = arguments.get("slug", "").strip() + dockerfile_path = arguments.get("dockerfile_path", "").strip() + port = arguments.get("port") + name = arguments.get("name", "").strip() + description = arguments.get("description", "").strip() + resource_suggestion = arguments.get("resource_limits_suggestion", {}) + + if not all([slug, dockerfile_path, port, name, description]): + return "Error: slug, dockerfile_path, port, name, and description are all required." + + slug_error = validate_slug(slug) + if slug_error: + return f"Error: {slug_error}" + + tenant_id = await _get_agent_tenant_id(agent_id) + + # Verify Dockerfile exists + dockerfile_full = (ws / dockerfile_path).resolve() + if not str(dockerfile_full).startswith(str(ws.resolve())): + return "Error: dockerfile_path must be within your workspace." + if not dockerfile_full.exists(): + return f"Error: Dockerfile not found at '{dockerfile_path}'." + + # Check/create project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + + if project and project.status == "deployed": + return f"Error: Slug '{slug}' is already deployed. Undeploy it first." + if project and project.status == "awaiting_approval": + return f"Error: Slug '{slug}' already has a pending approval request." + + if not project: + project = WorkspaceProject( + slug=slug, + name=name, + description=description, + tenant_id=tenant_id, + built_by=agent_id, + deploy_type="container", + status="awaiting_approval", + container_port=port, + dockerfile_path=dockerfile_path, + resource_limits=resource_suggestion if resource_suggestion else None, + ) + db.add(project) + else: + project.name = name + project.description = description + project.tenant_id = tenant_id + project.built_by = agent_id + project.deploy_type = "container" + project.status = "awaiting_approval" + project.container_port = port + project.dockerfile_path = dockerfile_path + project.resource_limits = resource_suggestion if resource_suggestion else None + + await db.commit() + + return ( + f"Container deployment request submitted for approval.\n" + f"- Slug: {slug}\n" + f"- Name: {name}\n" + f"- Dockerfile: {dockerfile_path}\n" + f"- Port: {port}\n" + f"- Suggested limits: {json.dumps(resource_suggestion) if resource_suggestion else 'none'}\n" + f"Frank will review and approve this deployment." + ) + + +async def tool_list_workspace_projects() -> str: + """List all workspace projects.""" + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).order_by(WorkspaceProject.created_at.desc()) + ) + projects = result.scalars().all() + + if not projects: + return "No workspace projects." + + lines = ["Workspace projects:\n"] + for p in projects: + url = f"/workspace/{p.slug}/" if p.status == "deployed" else "(not live)" + lines.append( + f"- [{p.slug}] {p.name} — {p.status} ({p.deploy_type})\n" + f" URL: {url}\n" + ) + return "\n".join(lines) + + +async def tool_undeploy_project(arguments: dict) -> str: + """Remove a deployed workspace project.""" + slug = arguments.get("slug", "").strip() + if not slug: + return "Error: 'slug' is required." + + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + project = result.scalar_one_or_none() + if not project: + return f"Error: Project '{slug}' not found." + + if project.deploy_type == "static": + # Delete static files + dest = Path(settings.WORKSPACE_STATIC_DIR) / slug + if dest.exists(): + shutil.rmtree(dest) + + elif project.deploy_type == "container" and project.container_id: + # Stop and remove container + try: + client = _get_docker_client() + try: + container = client.containers.get(project.container_id) + container.stop(timeout=10) + container.remove() + except Exception: + pass # container already gone or not found + client.close() + except Exception as e: + logger.exception("Failed to remove container for %s", slug) + return f"Warning: Container removal failed ({e}), but project will be marked as undeployed." + + # Remove nginx conf + conf_path = Path(settings.WORKSPACE_CONF_DIR) / f"{slug}.conf" + if conf_path.exists(): + conf_path.unlink() + + # Reload gateway nginx + await _reload_gateway() + + project.status = "undeployed" + await db.commit() + + # Regenerate index + try: + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after undeploy") + + return f"Project '{slug}' has been undeployed." + + +async def tool_get_bug_reports(arguments: dict) -> str: + """List bug reports, optionally filtered by status.""" + from sqlalchemy.orm import selectinload + + status_filter = arguments.get("status_filter", "open").strip() + + async with async_session() as db: + query = ( + select(WorkspaceBugReport) + .options(selectinload(WorkspaceBugReport.project)) + .join(WorkspaceProject) + ) + if status_filter != "all": + query = query.where(WorkspaceBugReport.status == status_filter) + query = query.order_by(WorkspaceBugReport.created_at.desc()) + result = await db.execute(query) + reports = result.scalars().all() + + if not reports: + return f"No bug reports with status '{status_filter}'." + + lines = [f"Bug reports ({status_filter}):\n"] + for r in reports: + proj_slug = r.project.slug if r.project else "unknown" + lines.append( + f"- [{r.id}] Project: {proj_slug}\n" + f" Source: {r.source} | Status: {r.status}\n" + f" Description: {r.description[:200]}\n" + f" Created: {r.created_at}\n" + ) + return "\n".join(lines) + + +async def tool_resolve_bug(arguments: dict) -> str: + """Mark a bug report as fixed.""" + bug_id = arguments.get("bug_report_id", "").strip() + if not bug_id: + return "Error: 'bug_report_id' is required." + + try: + bug_uuid = uuid.UUID(bug_id) + except ValueError: + return f"Error: Invalid UUID '{bug_id}'." + + async with async_session() as db: + report = await db.get(WorkspaceBugReport, bug_uuid) + if not report: + return f"Error: Bug report '{bug_id}' not found." + report.status = "fixed" + await db.commit() + + return f"Bug report {bug_id} marked as fixed." + + +async def tool_report_workspace_bug( + agent_id: uuid.UUID, arguments: dict +) -> str: + """Report a bug on a workspace project (agent-initiated).""" + slug = arguments.get("slug", "").strip() + description = arguments.get("description", "").strip() + + if not slug or not description: + return "Error: 'slug' and 'description' are required." + + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where( + WorkspaceProject.slug == slug, + WorkspaceProject.status == "deployed", + ) + ) + project = result.scalar_one_or_none() + if not project: + return f"Error: No deployed project found with slug '{slug}'." + + report = WorkspaceBugReport( + project_id=project.id, + source="user_report", + description=description[:2000], + ) + db.add(report) + await db.commit() + + return f"Bug report created for project '{slug}'. The Software Engineer agent will investigate." + + +async def approve_container_deploy( + slug: str, + resource_limits: dict | None = None, + *, + tenant_id: uuid.UUID | None = None, + include_platform_global: bool = False, +) -> dict: + """Approve and execute a container deployment. Returns status dict.""" + async with async_session() as db: + query = select(WorkspaceProject).where(WorkspaceProject.slug == slug) + query = _scope_workspace_project_query(query, tenant_id, include_platform_global) + result = await db.execute(query) + project = result.scalar_one_or_none() + if not project: + return {"ok": False, "error": f"Project '{slug}' not found."} + if project.status != "awaiting_approval": + return {"ok": False, "error": f"Project '{slug}' is not awaiting approval (status: {project.status})."} + + # Override resource limits if provided + if resource_limits: + project.resource_limits = resource_limits + + project.status = "building" + agent_id = project.built_by + port = project.container_port + limits = project.resource_limits or {} + await db.commit() + + # Resolve the requested Dockerfile deterministically from the stored path. + agent_ws = Path(settings.AGENT_DATA_DIR) / str(agent_id) + try: + dockerfile_path = _resolve_project_dockerfile(agent_ws, slug, project) + except (FileNotFoundError, ValueError) as exc: + async with async_session() as db: + proj = await db.get(WorkspaceProject, (await db.execute( + select(WorkspaceProject.id).where(WorkspaceProject.slug == slug) + )).scalar_one()) + proj.status = "failed" + await db.commit() + return {"ok": False, "error": str(exc)} + build_context = dockerfile_path.parent + + # Build and start container + try: + client = _get_docker_client() + container_name = f"ws-{slug}" + image_tag = f"ws-{slug}:latest" + + # Build the image + logger.info("Building Docker image '%s' from %s", image_tag, build_context) + image, build_logs = client.images.build( + path=str(build_context), + tag=image_tag, + rm=True, + ) + for log_line in build_logs: + if "stream" in log_line: + logger.debug("Build: %s", log_line["stream"].strip()) + + # Stop/remove existing container if any + try: + old = client.containers.get(container_name) + old.stop(timeout=10) + old.remove() + except Exception: + pass + + # Prepare container kwargs + container_kwargs = { + "image": image_tag, + "name": container_name, + "hostname": container_name, + "detach": True, + "restart_policy": {"Name": "unless-stopped"}, + "network": "workspace", + } + + # Apply resource limits + if limits.get("memory"): + container_kwargs["mem_limit"] = limits["memory"] + if limits.get("cpus"): + container_kwargs["nano_cpus"] = int(float(limits["cpus"]) * 1e9) + + # Start the container + logger.info("Starting container '%s'", container_name) + container = client.containers.run(**container_kwargs) + + client.close() + except Exception as e: + logger.exception("Failed to build/start container for '%s'", slug) + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + proj = result.scalar_one() + proj.status = "failed" + await db.commit() + return {"ok": False, "error": f"Docker build/start failed: {e}"} + + # Write nginx conf snippet + conf_content = f"""location /workspace/{slug}/ {{ + proxy_pass http://{container_name}:{port}/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +}} +""" + conf_path = Path(settings.WORKSPACE_CONF_DIR) / f"{slug}.conf" + conf_path.write_text(conf_content, encoding="utf-8") + logger.info("Wrote nginx conf for '%s'", slug) + + # Reload gateway + await _reload_gateway() + + # Update project record + async with async_session() as db: + result = await db.execute( + select(WorkspaceProject).where(WorkspaceProject.slug == slug) + ) + proj = result.scalar_one() + proj.status = "deployed" + proj.container_id = container.id + proj.container_image = image_tag + await db.commit() + + # Regenerate index + try: + from app.services.workspace_index import regenerate_index + await regenerate_index() + except Exception: + logger.exception("Index regeneration failed after container deploy") + + return { + "ok": True, + "slug": slug, + "container_name": container_name, + "url": f"/workspace/{slug}/", + } + + +async def reject_container_deploy( + slug: str, + *, + tenant_id: uuid.UUID | None = None, + include_platform_global: bool = False, +) -> dict: + """Reject a container deployment request.""" + async with async_session() as db: + query = select(WorkspaceProject).where(WorkspaceProject.slug == slug) + query = _scope_workspace_project_query(query, tenant_id, include_platform_global) + result = await db.execute(query) + project = result.scalar_one_or_none() + if not project: + return {"ok": False, "error": f"Project '{slug}' not found."} + if project.status != "awaiting_approval": + return {"ok": False, "error": f"Project '{slug}' is not awaiting approval (status: {project.status})."} + project.status = "rejected" + await db.commit() + return {"ok": True, "slug": slug, "status": "rejected"} + + +async def _reload_gateway() -> None: + """Reload the workspace gateway nginx config.""" + try: + client = _get_docker_client() + container = client.containers.get(settings.WORKSPACE_GATEWAY_CONTAINER) + container.exec_run("nginx -s reload") + client.close() + logger.info("Workspace gateway nginx reloaded") + except Exception: + logger.exception("Failed to reload workspace gateway nginx") diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index a11135649..c1d3a7e0e 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -11,6 +11,14 @@ if [ "$(id -u)" = '0' ]; then echo "[entrypoint] Detected root user, fixing permissions..." chown -R clawith:clawith ${AGENT_DATA_DIR} + # Add clawith to docker group (GID from mounted socket) for workspace container management + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || true) + if [ -n "$DOCKER_SOCK_GID" ] && [ "$DOCKER_SOCK_GID" != "0" ]; then + echo "[entrypoint] Adding clawith to docker socket group (GID=$DOCKER_SOCK_GID)..." + groupadd -g "$DOCKER_SOCK_GID" -o docker 2>/dev/null || true + usermod -aG docker clawith 2>/dev/null || true + fi + echo "[entrypoint] Dropping privileges to 'clawith' and re-executing..." exec gosu clawith /bin/bash "$0" "$@" fi @@ -44,7 +52,7 @@ if [ $ALEMBIC_EXIT -ne 0 ]; then echo " Source: git pull && alembic upgrade head" echo "------------------------------------------------------------------------" echo "" - echo "[entrypoint] Continuing startup despite migration failure..." + exit $ALEMBIC_EXIT else echo "[entrypoint] Alembic migrations completed successfully." fi diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1ec45860f..23e8412c7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -60,6 +60,9 @@ dev = [ target-version = "py311" line-length = 120 +[tool.ruff.lint] +ignore = ["E402", "E701"] + [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/backend/remove_old_tool.py b/backend/remove_old_tool.py index 52d2469b8..1a0b485d1 100644 --- a/backend/remove_old_tool.py +++ b/backend/remove_old_tool.py @@ -1,9 +1,11 @@ import asyncio + from sqlalchemy.future import select -from sqlalchemy import delete + from app.db.session import async_session_maker from app.models.tool import Tool + async def run(): async with async_session_maker() as session: result = await session.execute(select(Tool).where(Tool.name == "generate_image")) @@ -18,5 +20,6 @@ async def run(): else: print("Tool 'generate_image' not found in database.") + if __name__ == "__main__": asyncio.run(run()) diff --git a/backend/seed.py b/backend/seed.py index bb9ce7c88..ded473238 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -5,7 +5,6 @@ sys.path.insert(0, ".") from app.config import get_settings -from app.core.security import hash_password from app.database import Base, engine, async_session # Import ALL models so Base.metadata.create_all can resolve all FKs from app.models.tenant import Tenant # noqa: F401 — must be before user diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 000000000..04d7f905b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9eb500f4f..3b6f93045 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -182,7 +182,7 @@ async def test_get_me_returns_user(): with patch("app.api.auth.UserOut") as MockUserOut: MockUserOut.model_validate.return_value = {"id": str(user.id), "email": user.email} - result = await auth_api.get_me(current_user=user) + await auth_api.get_me(current_user=user) MockUserOut.model_validate.assert_called_once_with(user) diff --git a/backend/tests/test_chat_session_service.py b/backend/tests/test_chat_session_service.py new file mode 100644 index 000000000..4eeaf1aff --- /dev/null +++ b/backend/tests/test_chat_session_service.py @@ -0,0 +1,54 @@ +import uuid +from types import SimpleNamespace + +import pytest + +from app.services import chat_session_service + + +class DummyResult: + def __init__(self, value=None): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class RecordingDB: + def __init__(self, responses=None): + self.responses = list(responses or []) + self.statements = [] + self.flushed = False + + async def execute(self, statement): + self.statements.append(str(statement)) + return self.responses.pop(0) if self.responses else DummyResult() + + async def flush(self): + self.flushed = True + + def add(self, _value): + pass + + +@pytest.mark.asyncio +async def test_get_primary_platform_session_uses_sqlalchemy_boolean_predicates(): + db = RecordingDB([DummyResult(None)]) + + await chat_session_service.get_primary_platform_session(db, uuid.uuid4(), uuid.uuid4()) + + sql = db.statements[0] + assert "chat_sessions.is_group IS false" in sql + assert "chat_sessions.is_primary IS true" in sql + + +@pytest.mark.asyncio +async def test_ensure_primary_platform_session_promotes_existing_session(): + session = SimpleNamespace(is_primary=False) + db = RecordingDB([DummyResult(None), DummyResult(session)]) + + result = await chat_session_service.ensure_primary_platform_session(db, uuid.uuid4(), uuid.uuid4()) + + assert result is session + assert session.is_primary is True + assert db.flushed is True diff --git a/backend/tests/test_enterprise_tenant_quotas.py b/backend/tests/test_enterprise_tenant_quotas.py new file mode 100644 index 000000000..1a4119e1e --- /dev/null +++ b/backend/tests/test_enterprise_tenant_quotas.py @@ -0,0 +1,117 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.api import enterprise as enterprise_api + + +class DummyResult: + def __init__(self, values=None): + self._values = list(values or []) + + def scalar_one_or_none(self): + return self._values[0] if self._values else None + + +class RecordingDB: + def __init__(self, responses=None): + self.responses = list(responses or []) + self.committed = False + + async def execute(self, _statement): + return self.responses.pop(0) if self.responses else DummyResult() + + async def commit(self): + self.committed = True + + +def _tenant(tenant_id: uuid.UUID, utility_model_id: uuid.UUID | None = None): + return SimpleNamespace( + id=tenant_id, + default_message_limit=50, + default_message_period="permanent", + default_max_agents=2, + default_agent_ttl_hours=48, + default_max_llm_calls_per_day=100, + min_heartbeat_interval_minutes=120, + default_max_triggers=20, + min_poll_interval_floor=5, + max_webhook_rate_ceiling=5, + utility_model_id=utility_model_id, + ) + + +def _model(model_id: uuid.UUID, tenant_id: uuid.UUID, enabled: bool = True): + return SimpleNamespace(id=model_id, tenant_id=tenant_id, enabled=enabled) + + +@pytest.mark.asyncio +async def test_get_tenant_quotas_platform_admin_can_read_selected_tenant(): + selected_tenant_id = uuid.uuid4() + tenant = _tenant(selected_tenant_id) + current_user = SimpleNamespace(role="platform_admin", tenant_id=uuid.uuid4(), identity=None) + db = RecordingDB([DummyResult([tenant])]) + + result = await enterprise_api.get_tenant_quotas( + tenant_id=str(selected_tenant_id), + current_user=current_user, + db=db, + ) + + assert result["default_max_agents"] == 2 + + +@pytest.mark.asyncio +async def test_get_tenant_quotas_org_admin_cannot_read_other_tenant(): + current_user = SimpleNamespace(role="org_admin", tenant_id=uuid.uuid4(), identity=None) + db = RecordingDB() + + with pytest.raises(HTTPException) as exc: + await enterprise_api.get_tenant_quotas( + tenant_id=str(uuid.uuid4()), + current_user=current_user, + db=db, + ) + + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_tenant_quotas_platform_admin_can_set_selected_tenant_utility_model(monkeypatch): + selected_tenant_id = uuid.uuid4() + utility_model_id = uuid.uuid4() + tenant = _tenant(selected_tenant_id) + model = _model(utility_model_id, selected_tenant_id) + current_user = SimpleNamespace(role="platform_admin", tenant_id=uuid.uuid4(), identity=None) + db = RecordingDB([DummyResult([tenant]), DummyResult([model])]) + monkeypatch.setattr(enterprise_api, "enforce_heartbeat_floor", AsyncMock(return_value=0), raising=False) + + result = await enterprise_api.update_tenant_quotas( + enterprise_api.TenantQuotaUpdate(utility_model_id=str(utility_model_id)), + tenant_id=str(selected_tenant_id), + current_user=current_user, + db=db, + ) + + assert tenant.utility_model_id == utility_model_id + assert db.committed is True + assert result["message"] == "Tenant quotas updated" + + +@pytest.mark.asyncio +async def test_update_tenant_quotas_org_admin_cannot_write_other_tenant(): + current_user = SimpleNamespace(role="org_admin", tenant_id=uuid.uuid4(), identity=None) + db = RecordingDB() + + with pytest.raises(HTTPException) as exc: + await enterprise_api.update_tenant_quotas( + enterprise_api.TenantQuotaUpdate(default_max_agents=5), + tenant_id=str(uuid.uuid4()), + current_user=current_user, + db=db, + ) + + assert exc.value.status_code == 403 diff --git a/backend/tests/test_files_api.py b/backend/tests/test_files_api.py index 00568243a..8da10eccb 100644 --- a/backend/tests/test_files_api.py +++ b/backend/tests/test_files_api.py @@ -1,84 +1,405 @@ +import io import uuid +import zipfile +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from fastapi import HTTPException +from starlette.datastructures import UploadFile from app.api import files as files_api -from app.models.agent import Agent -from app.models.user import User - - -def make_user(**overrides): - values = { - "id": uuid.uuid4(), - "display_name": "Alice", - "role": "member", - "tenant_id": uuid.uuid4(), - "is_active": True, - } - values.update(overrides) - return User(**values) - - -def make_agent(creator_id: uuid.UUID, **overrides): - values = { - "id": uuid.uuid4(), - "name": "Ops Bot", - "role_description": "assistant", - "creator_id": creator_id, - "status": "idle", - "agent_type": "native", - } - values.update(overrides) - return Agent(**values) +from app.services import skill_map + + +def _zip_upload(entries: dict[str, bytes], filename: str = "skills.zip") -> UploadFile: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for path, content in entries.items(): + zf.writestr(path, content) + buffer.seek(0) + return UploadFile(filename=filename, file=buffer) @pytest.mark.asyncio -async def test_use_access_cannot_delete_agent_workspace_file(monkeypatch, tmp_path): - user = make_user() - agent = make_agent(uuid.uuid4(), tenant_id=user.tenant_id) - workspace_file = tmp_path / str(agent.id) / "workspace" / "important.md" - workspace_file.parent.mkdir(parents=True) - workspace_file.write_text("do not delete", encoding="utf-8") +async def test_preview_zip_reports_root_folder_and_file_count(monkeypatch): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({ + "starter-skill/SKILL.md": b"# Starter\n", + "starter-skill/scripts/run.py": b"print('ok')\n", + }) - async def fake_check_agent_access(_db, _current_user, _agent_id): - return agent, "use" + result = await files_api.preview_zip( + agent_id, + upload, + current_user=current_user, + db=object(), + ) + assert result["root_folder"] == "starter-skill" + assert result["total"] == 2 + assert "starter-skill/SKILL.md" in result["files"] + + +@pytest.mark.asyncio +async def test_extract_zip_strips_archive_root_and_writes_under_skills(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(files_api, "check_agent_access", fake_check_agent_access) + monkeypatch.setattr(skill_map, "invalidate_cache", lambda _agent_id: None) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({ + "starter-skill/SKILL.md": b"# Starter\n", + "starter-skill/examples/demo.md": b"demo\n", + }) + + result = await files_api.extract_zip( + agent_id, + upload, + target_path="imports", + root_name="", + current_user=current_user, + db=object(), + ) + + skill_md = tmp_path / str(agent_id) / "skills" / "imports" / "SKILL.md" + demo_md = tmp_path / str(agent_id) / "skills" / "imports" / "examples" / "demo.md" + assert result["extracted"] == 2 + assert "imports/SKILL.md" in result["files"] + assert skill_md.read_text() == "# Starter\n" + assert demo_md.read_text() == "demo\n" + + +@pytest.mark.asyncio +async def test_extract_zip_rejects_invalid_root_name(monkeypatch): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({"starter-skill/SKILL.md": b"# Starter\n"}) with pytest.raises(HTTPException) as exc: - await files_api.delete_file( - agent_id=agent.id, - path="workspace/important.md", - current_user=user, + await files_api.extract_zip( + agent_id, + upload, + target_path="", + root_name="../bad", + current_user=current_user, + db=object(), + ) + + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_extract_zip_wraps_flat_archive_under_custom_root_name(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(skill_map, "invalidate_cache", lambda _agent_id: None) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({ + "SKILL.md": b"# Flat Skill\n", + "README.md": b"hello\n", + }) + + result = await files_api.extract_zip( + agent_id, + upload, + target_path="imports", + root_name="custom-pack", + current_user=current_user, + db=object(), + ) + + skill_md = tmp_path / str(agent_id) / "skills" / "imports" / "custom-pack" / "SKILL.md" + readme_md = tmp_path / str(agent_id) / "skills" / "imports" / "custom-pack" / "README.md" + assert result["extracted"] == 2 + assert "imports/custom-pack/SKILL.md" in result["files"] + assert "imports/custom-pack/README.md" in result["files"] + assert skill_md.read_text() == "# Flat Skill\n" + assert readme_md.read_text() == "hello\n" + + +@pytest.mark.asyncio +async def test_extract_zip_wraps_mixed_archive_under_custom_root_name(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(skill_map, "invalidate_cache", lambda _agent_id: None) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({ + "root-skill/SKILL.md": b"# Rooted\n", + "shared/util.py": b"print('util')\n", + "notes.md": b"mixed\n", + }) + + result = await files_api.extract_zip( + agent_id, + upload, + target_path="imports", + root_name="bundle", + current_user=current_user, + db=object(), + ) + + rooted = tmp_path / str(agent_id) / "skills" / "imports" / "bundle" / "root-skill" / "SKILL.md" + shared = tmp_path / str(agent_id) / "skills" / "imports" / "bundle" / "shared" / "util.py" + notes = tmp_path / str(agent_id) / "skills" / "imports" / "bundle" / "notes.md" + assert result["extracted"] == 3 + assert "imports/bundle/root-skill/SKILL.md" in result["files"] + assert "imports/bundle/shared/util.py" in result["files"] + assert "imports/bundle/notes.md" in result["files"] + assert rooted.read_text() == "# Rooted\n" + assert shared.read_text() == "print('util')\n" + assert notes.read_text() == "mixed\n" + + +@pytest.mark.asyncio +async def test_list_files_hides_openclaw_directory(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + + agent_id = uuid.uuid4() + agent_dir = tmp_path / str(agent_id) + (agent_dir / ".openclaw").mkdir(parents=True) + (agent_dir / ".openclaw" / "openclaw.json").write_text('{"env":{"OPENAI_API_KEY":"sk-secret"}}', encoding="utf-8") + (agent_dir / "workspace").mkdir() + (agent_dir / "workspace" / "notes.md").write_text("hello", encoding="utf-8") + + current_user = SimpleNamespace(id=uuid.uuid4(), role="member", tenant_id=None, identity=None) + + items = await files_api.list_files( + agent_id, + path="", + current_user=current_user, + db=object(), + ) + + names = {item.name for item in items} + assert ".openclaw" not in names + assert "workspace" in names + + +@pytest.mark.asyncio +async def test_read_file_blocks_openclaw_config(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + + agent_id = uuid.uuid4() + protected = tmp_path / str(agent_id) / ".openclaw" + protected.mkdir(parents=True) + (protected / "openclaw.json").write_text("{}", encoding="utf-8") + + current_user = SimpleNamespace(id=uuid.uuid4(), role="member", tenant_id=None, identity=None) + + with pytest.raises(HTTPException) as exc: + await files_api.read_file( + agent_id, + path=".openclaw/openclaw.json", + current_user=current_user, db=object(), ) assert exc.value.status_code == 403 - assert workspace_file.exists() @pytest.mark.asyncio -async def test_manage_access_can_delete_agent_workspace_file(monkeypatch, tmp_path): - user = make_user() - agent = make_agent(user.id, tenant_id=user.tenant_id) - workspace_file = tmp_path / str(agent.id) / "workspace" / "obsolete.md" - workspace_file.parent.mkdir(parents=True) - workspace_file.write_text("delete me", encoding="utf-8") +async def test_preview_skill_folder_reports_update_diff(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) - async def fake_check_agent_access(_db, _current_user, _agent_id): - return agent, "manage" + existing_root = tmp_path / str(agent_id) / "skills" / "demo-skill" + existing_root.mkdir(parents=True) + (existing_root / "SKILL.md").write_text("# Old\n", encoding="utf-8") + (existing_root / "stale.txt").write_text("remove me\n", encoding="utf-8") + upload = _zip_upload({ + "demo-skill/SKILL.md": b"# New\n", + "demo-skill/scripts/run.py": b"print('ok')\n", + }) + + result = await files_api.preview_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + current_user=current_user, + db=object(), + ) + + assert result["mode"] == "update" + assert result["changed_count"] == 1 + assert result["added_count"] == 1 + assert result["deleted_count"] == 1 + assert result["deleted_paths"] == ["stale.txt"] + + +@pytest.mark.asyncio +async def test_apply_skill_folder_exact_sync_removes_stale_files(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(skill_map, "invalidate_cache", lambda _agent_id: None) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + + existing_root = tmp_path / str(agent_id) / "skills" / "demo-skill" + existing_root.mkdir(parents=True) + (existing_root / "SKILL.md").write_text("# Old\n", encoding="utf-8") + (existing_root / "stale.txt").write_text("remove me\n", encoding="utf-8") + + upload = _zip_upload({ + "demo-skill/SKILL.md": b"# New\n", + "demo-skill/scripts/run.py": b"print('ok')\n", + }) + + preview = await files_api.preview_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + current_user=current_user, + db=object(), + ) + + upload = _zip_upload({ + "demo-skill/SKILL.md": b"# New\n", + "demo-skill/scripts/run.py": b"print('ok')\n", + }) + result = await files_api.apply_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + replace_confirmed=True, + expected_digest=preview["digest"], + expected_target_state_digest=preview["target_state_digest"], + current_user=current_user, + db=object(), + ) + + assert result["mode"] == "update" + assert (existing_root / "SKILL.md").read_text() == "# New\n" + assert (existing_root / "scripts" / "run.py").read_text() == "print('ok')\n" + assert not (existing_root / "stale.txt").exists() + + +@pytest.mark.asyncio +async def test_preview_skill_folder_rejects_missing_skill_md(monkeypatch): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + upload = _zip_upload({"demo-skill/readme.md": b"oops\n"}) + + with pytest.raises(HTTPException) as exc: + await files_api.preview_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + current_user=current_user, + db=object(), + ) + + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_apply_skill_folder_requires_confirmation_for_existing_target(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + skill_root = tmp_path / str(agent_id) / "skills" / "demo-skill" + skill_root.mkdir(parents=True) + (skill_root / "SKILL.md").write_text("# Old\n", encoding="utf-8") + + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + preview = await files_api.preview_skill_folder_upload( + agent_id, upload, target_folder="demo-skill", current_user=current_user, db=object() + ) + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + + with pytest.raises(HTTPException) as exc: + await files_api.apply_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + replace_confirmed=False, + expected_digest=preview["digest"], + current_user=current_user, + db=object(), + ) + + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_empty_existing_target_folder_is_treated_as_update_and_requires_confirmation(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(files_api, "check_agent_access", fake_check_agent_access) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + skill_root = tmp_path / str(agent_id) / "skills" / "demo-skill" + skill_root.mkdir(parents=True) + + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + preview = await files_api.preview_skill_folder_upload( + agent_id, upload, target_folder="demo-skill", current_user=current_user, db=object() + ) - result = await files_api.delete_file( - agent_id=agent.id, - path="workspace/obsolete.md", - current_user=user, + assert preview["mode"] == "update" + + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + with pytest.raises(HTTPException) as exc: + await files_api.apply_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + replace_confirmed=False, + expected_digest=preview["digest"], + current_user=current_user, + db=object(), + ) + + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_apply_skill_folder_rejects_target_state_changes_after_preview(monkeypatch, tmp_path): + monkeypatch.setattr(files_api, "check_agent_access", AsyncMock()) + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(skill_map, "invalidate_cache", lambda _agent_id: None) + agent_id = uuid.uuid4() + current_user = SimpleNamespace(id=uuid.uuid4(), role="org_admin", tenant_id=uuid.uuid4(), identity=None) + + existing_root = tmp_path / str(agent_id) / "skills" / "demo-skill" + existing_root.mkdir(parents=True) + (existing_root / "SKILL.md").write_text("# Old\n", encoding="utf-8") + + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + preview = await files_api.preview_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + current_user=current_user, db=object(), ) - assert result == {"status": "ok", "path": "workspace/obsolete.md"} - assert not workspace_file.exists() + (existing_root / "late.txt").write_text("appeared later\n", encoding="utf-8") + + upload = _zip_upload({"demo-skill/SKILL.md": b"# New\n"}) + with pytest.raises(HTTPException) as exc: + await files_api.apply_skill_folder_upload( + agent_id, + upload, + target_folder="demo-skill", + replace_confirmed=True, + expected_digest=preview["digest"], + expected_target_state_digest=preview["target_state_digest"], + current_user=current_user, + db=object(), + ) + + assert exc.value.status_code == 409 + assert (existing_root / "late.txt").exists() + assert (existing_root / "SKILL.md").read_text() == "# Old\n" diff --git a/backend/tests/test_legacy_schema_compat_migration.py b/backend/tests/test_legacy_schema_compat_migration.py new file mode 100644 index 000000000..7d8d13f09 --- /dev/null +++ b/backend/tests/test_legacy_schema_compat_migration.py @@ -0,0 +1,50 @@ +from pathlib import Path + + +def test_workspace_deployment_head_carries_legacy_schema_compatibility_guards(): + repo_root = Path(__file__).resolve().parents[1] + migration_text = ( + repo_root / "alembic" / "versions" / "add_workspace_deployment_tables.py" + ).read_text(encoding="utf-8") + + expected_snippets = [ + "ALTER TABLE workspace_projects ADD COLUMN IF NOT EXISTS tenant_id", + "ALTER TABLE workspace_projects ADD COLUMN IF NOT EXISTS dockerfile_path", + "ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS is_system", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_today", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_month", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_read_tokens_total", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_today", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_month", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS cache_creation_tokens_total", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS is_system", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS access_mode", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS company_access_level", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS identity_id", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS registration_source", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS country_region", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS sso_enabled", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS sso_domain", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS a2a_async_enabled", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS default_model_id", + "ALTER TABLE tenants ADD COLUMN IF NOT EXISTS utility_model_id", + "ALTER TABLE tools ADD COLUMN IF NOT EXISTS source", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS default_mcp_servers", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS capability_bullets", + "ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS bootstrap_content", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS open_id", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS unionid", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS external_id", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS provider_id", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS user_id", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS name_translit_full", + "ALTER TABLE org_members ADD COLUMN IF NOT EXISTS name_translit_initial", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS updated_at", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS created_by_user_id", + "ALTER TABLE agent_agent_relationships ADD COLUMN IF NOT EXISTS updated_by_user_id", + 'for value in ("wechat", "whatsapp", "agentbay")', + "ALTER TYPE channel_type_enum ADD VALUE IF NOT EXISTS", + ] + + missing = [snippet for snippet in expected_snippets if snippet not in migration_text] + assert missing == [] diff --git a/backend/tests/test_main_workspace_wiring.py b/backend/tests/test_main_workspace_wiring.py new file mode 100644 index 000000000..3de86161b --- /dev/null +++ b/backend/tests/test_main_workspace_wiring.py @@ -0,0 +1,12 @@ +import importlib + + +def test_app_main_imports_and_registers_workspace_routes(): + main = importlib.import_module("app.main") + + paths = {route.path for route in main.app.routes} + + assert "/api/workspace/projects" in paths + assert "/api/workspace/projects/{slug}/approve" in paths + assert "/api/workspace/projects/{slug}/reject" in paths + assert "/api/workspace/projects/{slug}/report-bug" in paths diff --git a/backend/tests/test_notification_migration_regression.py b/backend/tests/test_notification_migration_regression.py new file mode 100644 index 000000000..e25fdc861 --- /dev/null +++ b/backend/tests/test_notification_migration_regression.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def test_notifications_table_has_forward_migration(): + repo_root = Path(__file__).resolve().parents[2] + versions_dir = repo_root / "backend" / "alembic" / "versions" + create_markers = ( + 'create_table("notifications"', + "create_table('notifications'", + "CREATE TABLE notifications", + "CREATE TABLE IF NOT EXISTS notifications", + ) + + has_creator = any( + any(marker in path.read_text(encoding="utf-8") for marker in create_markers) + for path in versions_dir.glob("*.py") + ) + + assert has_creator, "Expected an Alembic migration to create notifications for legacy installs" diff --git a/backend/tests/test_password_reset_and_notifications.py b/backend/tests/test_password_reset_and_notifications.py index 03ddec433..755486455 100644 --- a/backend/tests/test_password_reset_and_notifications.py +++ b/backend/tests/test_password_reset_and_notifications.py @@ -115,7 +115,6 @@ async def test_create_password_reset_token_invalidates_older_tokens(monkeypatch) async def fake_get_redis(): return mock_redis monkeypatch.setattr(password_reset_service, "get_redis", fake_get_redis) - db = RecordingDB() user_id = uuid.uuid4() raw_token, expires_at = await password_reset_service.create_password_reset_token(user_id) @@ -158,7 +157,6 @@ async def test_consume_password_reset_token_works_correctly(monkeypatch): async def fake_get_redis(): return mock_redis monkeypatch.setattr(password_reset_service, "get_redis", fake_get_redis) - db = RecordingDB() result = await password_reset_service.consume_password_reset_token(raw_token) assert result is not None diff --git a/backend/tests/test_review_regressions.py b/backend/tests/test_review_regressions.py new file mode 100644 index 000000000..5daab9fe1 --- /dev/null +++ b/backend/tests/test_review_regressions.py @@ -0,0 +1,169 @@ +import uuid +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from app.api import websocket as websocket_api +from app.services.agent_manager import AgentManager + + +class DummyResult: + def __init__(self, values=None): + self._values = list(values or []) + + def scalar_one_or_none(self): + return self._values[0] if self._values else None + + def scalars(self): + return self + + def all(self): + return list(self._values) + + +class RecordingDB: + def __init__(self, responses=None): + self.responses = list(responses or []) + + async def execute(self, _statement, _params=None): + if not self.responses: + raise AssertionError("unexpected execute() call") + return self.responses.pop(0) + + +class DummyContainer: + id = "container-123" + + +class DummyContainers: + def __init__(self): + self.calls = [] + + def run(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return DummyContainer() + + +class DummyDockerClient: + def __init__(self): + self.containers = DummyContainers() + + +@pytest.mark.asyncio +async def test_openclaw_config_does_not_persist_model_api_key(monkeypatch, tmp_path): + monkeypatch.setattr("app.services.agent_manager.settings.AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr("app.services.agent_manager.get_model_api_key", lambda _model: "sk-secret") + + manager = AgentManager() + manager.docker_client = DummyDockerClient() + + agent = SimpleNamespace( + id=uuid.uuid4(), + name="Review Bot", + primary_model_id=uuid.uuid4(), + container_id=None, + container_port=None, + status="idle", + last_active_at=None, + ) + model = SimpleNamespace(provider="openai", model="gpt-4.1") + db = RecordingDB([DummyResult([model])]) + + await manager.start_container(db, agent) + + config_path = tmp_path / str(agent.id) / ".openclaw" / "openclaw.json" + config_text = config_path.read_text(encoding="utf-8") + assert "sk-secret" not in config_text + + _, kwargs = manager.docker_client.containers.calls[0] + assert kwargs["environment"]["OPENAI_API_KEY"] == "sk-secret" + assert kwargs["environment"]["OPENCLAW_GATEWAY_TOKEN"] + + +@pytest.mark.asyncio +async def test_get_chat_history_preserves_hidden_messages(monkeypatch): + now = datetime.now(UTC) + hidden = SimpleNamespace( + id=uuid.uuid4(), + role="user", + content="# Loaded skill\nbody", + created_at=now, + thinking=None, + is_hidden=True, + ) + visible = SimpleNamespace( + id=uuid.uuid4(), + role="assistant", + content="Visible reply", + created_at=now, + thinking=None, + is_hidden=False, + ) + db = RecordingDB([DummyResult([hidden, visible])]) + current_user = SimpleNamespace(id=uuid.uuid4()) + + history = await websocket_api.get_chat_history( + agent_id=uuid.uuid4(), + current_user=current_user, + db=db, + ) + + assert [entry["id"] for entry in history] == [str(hidden.id), str(visible.id)] + assert history[0]["is_hidden"] is True + assert history[0]["content"] == hidden.content + + +def test_docker_compose_agent_network_matches_defined_network(): + repo_root = Path(__file__).resolve().parents[2] + compose = yaml.safe_load((repo_root / "docker-compose.yml").read_text(encoding="utf-8")) + + backend_network = compose["services"]["backend"]["environment"]["DOCKER_NETWORK"] + defined_network = compose["networks"]["default"]["name"] + + assert backend_network == defined_network + + +def test_helm_secrets_template_renders_required_keys(): + repo_root = Path(__file__).resolve().parents[2] + template = (repo_root / "helm" / "clawith" / "templates" / "secrets.yaml").read_text(encoding="utf-8") + + assert "kind: Secret" in template + assert "secret-key" in template + assert "jwt-secret-key" in template + + +def test_restart_script_keeps_default_postgres_port_and_fails_migrations(): + repo_root = Path(__file__).resolve().parents[2] + script = (repo_root / "restart.sh").read_text(encoding="utf-8") + + assert 'PG_PORT="$PG_HOST"' not in script + assert 'if [ "$PG_PORT" = "$PG_HOST" ]; then' in script + assert ".venv/bin/alembic upgrade head 2>/dev/null || true" not in script + + +def test_entrypoint_fails_fast_on_migration_error(): + repo_root = Path(__file__).resolve().parents[2] + script = (repo_root / "backend" / "entrypoint.sh").read_text(encoding="utf-8") + + assert "Continuing startup despite migration failure" not in script + assert "exit $ALEMBIC_EXIT" in script + + +def test_backend_startup_does_not_run_metadata_create_all(): + repo_root = Path(__file__).resolve().parents[2] + main_text = (repo_root / "backend" / "app" / "main.py").read_text(encoding="utf-8") + + assert "run_sync(Base.metadata.create_all)" not in main_text + + +def test_onboarding_migration_skips_existing_table_creation(): + repo_root = Path(__file__).resolve().parents[2] + migration_text = ( + repo_root / "backend" / "alembic" / "versions" / "add_user_tenant_onboarding.py" + ).read_text(encoding="utf-8") + + assert "has_table" in migration_text + diff --git a/backend/tests/test_schema_bootstrap_regressions.py b/backend/tests/test_schema_bootstrap_regressions.py new file mode 100644 index 000000000..e7142c7a9 --- /dev/null +++ b/backend/tests/test_schema_bootstrap_regressions.py @@ -0,0 +1,41 @@ +import ast +from pathlib import Path + + +def _imported_model_modules(source_text: str) -> set[str]: + tree = ast.parse(source_text) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("app.models."): + imported.add(node.module.removeprefix("app.models.").split(".", 1)[0]) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("app.models."): + imported.add(alias.name.removeprefix("app.models.").split(".", 1)[0]) + return imported + + +def test_alembic_env_imports_all_model_modules_for_initial_schema(): + repo_root = Path(__file__).resolve().parents[1] + env_text = (repo_root / "alembic" / "env.py").read_text(encoding="utf-8") + model_modules = { + path.stem + for path in (repo_root / "app" / "models").glob("*.py") + if path.stem != "__init__" + } + + missing = sorted(model_modules - _imported_model_modules(env_text)) + assert missing == [] + + +def test_bootstrap_db_imports_all_model_modules_before_create_all(): + repo_root = Path(__file__).resolve().parents[1] + bootstrap_text = (repo_root / "app" / "scripts" / "bootstrap_db.py").read_text(encoding="utf-8") + model_modules = { + path.stem + for path in (repo_root / "app" / "models").glob("*.py") + if path.stem != "__init__" + } + + missing = sorted(model_modules - _imported_model_modules(bootstrap_text)) + assert missing == [] diff --git a/backend/tests/test_skill_archive_import.py b/backend/tests/test_skill_archive_import.py new file mode 100644 index 000000000..26572698e --- /dev/null +++ b/backend/tests/test_skill_archive_import.py @@ -0,0 +1,96 @@ +import io +import zipfile + +import pytest +from fastapi import HTTPException + +from app.services.skill_archive_import import inspect_skill_archive, diff_skill_manifests + + +def _zip_bytes(entries: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for path, content in entries.items(): + zf.writestr(path, content) + return buffer.getvalue() + + +def test_inspect_skill_archive_requires_root_skill_md(): + data = _zip_bytes({"demo/readme.md": b"missing root skill"}) + + with pytest.raises(HTTPException) as exc: + inspect_skill_archive(data, target_folder="demo") + + assert exc.value.status_code == 400 + assert "SKILL.md" in str(exc.value.detail) + + +def test_diff_skill_manifests_reports_added_changed_deleted(): + uploaded = { + "SKILL.md": "# New\n", + "scripts/run.py": "print('new')\n", + } + existing = { + "SKILL.md": "# Old\n", + "docs/notes.md": "stale\n", + } + + diff = diff_skill_manifests(uploaded, existing) + + assert diff["added"] == ["scripts/run.py"] + assert diff["changed"] == ["SKILL.md"] + assert diff["deleted"] == ["docs/notes.md"] + + +def test_inspect_skill_archive_strips_single_root_folder(): + data = _zip_bytes({"demo-skill/SKILL.md": b"# Demo\n", "demo-skill/scripts/run.py": b"print('ok')\n"}) + + result = inspect_skill_archive(data, target_folder="demo-skill") + + assert sorted(result["files"].keys()) == ["SKILL.md", "scripts/run.py"] + + +def test_inspect_skill_archive_rejects_parent_traversal(): + data = _zip_bytes({"demo/../secrets.txt": b"bad", "demo/SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException): + inspect_skill_archive(data, target_folder="demo") + + +def test_inspect_skill_archive_preserves_root_files_without_common_folder(): + data = _zip_bytes({"SKILL.md": b"# Demo\n", "scripts/run.py": b"print('ok')\n"}) + + result = inspect_skill_archive(data, target_folder="demo") + + assert sorted(result["files"].keys()) == ["SKILL.md", "scripts/run.py"] + + +def test_inspect_skill_archive_rejects_leading_slash_paths(): + data = _zip_bytes({"/etc/passwd": b"bad", "SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException): + inspect_skill_archive(data, target_folder="demo") + + +def test_inspect_skill_archive_rejects_windows_style_parent_traversal(): + data = _zip_bytes({r"..\\evil.txt": b"bad", "SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException): + inspect_skill_archive(data, target_folder="demo") + + +def test_inspect_skill_archive_rejects_windows_absolute_drive_paths(): + data = _zip_bytes({"C:/evil.txt": b"bad", "SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException): + inspect_skill_archive(data, target_folder="demo") + + +def test_inspect_skill_archive_rejects_non_utf8_files(): + data = _zip_bytes({"SKILL.md": b"# Demo\n", "assets/icon.bin": b"\xff\xfe\xfd"}) + + with pytest.raises(HTTPException) as exc: + inspect_skill_archive(data, target_folder="demo") + + assert exc.value.status_code == 400 + assert "UTF-8" in str(exc.value.detail) diff --git a/backend/tests/test_skill_folder_zip_compat.py b/backend/tests/test_skill_folder_zip_compat.py new file mode 100644 index 000000000..243c102e2 --- /dev/null +++ b/backend/tests/test_skill_folder_zip_compat.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +def test_build_skill_folder_zip_source_marks_filenames_as_utf8(): + repo_root = Path(__file__).resolve().parents[2] + source = (repo_root / "frontend" / "src" / "utils" / "skillFolderZip.ts").read_text() + + assert "const ZIP_UTF8_FLAG = 0x0800;" in source + assert "localHeader.setUint16(6, ZIP_UTF8_FLAG, true);" in source + assert "centralHeader.setUint16(8, ZIP_UTF8_FLAG, true);" in source diff --git a/backend/tests/test_skill_map_regression.py b/backend/tests/test_skill_map_regression.py new file mode 100644 index 000000000..500cc4c24 --- /dev/null +++ b/backend/tests/test_skill_map_regression.py @@ -0,0 +1,66 @@ +import sys +import uuid +from types import SimpleNamespace + + +class _NoopLogger: + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + +sys.modules.setdefault("loguru", SimpleNamespace(logger=_NoopLogger())) + +from app.services import agent_context, skill_map + + +def _write_skill(root, agent_id: uuid.UUID, folder: str = "qa") -> str: + skill_dir = root / str(agent_id) / "skills" / folder + skill_dir.mkdir(parents=True, exist_ok=True) + content = """--- +name: QA +description: Regression test skill +emoji: test +--- +# QA + +Use this for regression testing. +""" + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + return content + + +def test_get_skill_map_uses_canonical_agent_workspace(tmp_path, monkeypatch): + agent_id = uuid.uuid4() + _write_skill(tmp_path, agent_id) + + monkeypatch.setattr(agent_context, "PERSISTENT_DATA", tmp_path) + skill_map.invalidate_cache(agent_id) + + result = skill_map.get_skill_map(agent_id) + + assert result == { + "qa": { + "name": "QA", + "description": "Regression test skill", + "emoji": "test", + "file": "qa/SKILL.md", + } + } + + +def test_get_skill_map_for_api_strips_file_paths(tmp_path, monkeypatch): + agent_id = uuid.uuid4() + _write_skill(tmp_path, agent_id) + + monkeypatch.setattr(agent_context, "PERSISTENT_DATA", tmp_path) + skill_map.invalidate_cache(agent_id) + + result = skill_map.get_skill_map_for_api(agent_id) + + assert result == { + "qa": { + "name": "QA", + "description": "Regression test skill", + "emoji": "test", + } + } diff --git a/backend/tests/test_skills_api.py b/backend/tests/test_skills_api.py index 2ca5f8dc4..c7ad8aa54 100644 --- a/backend/tests/test_skills_api.py +++ b/backend/tests/test_skills_api.py @@ -1,11 +1,14 @@ +import io +import zipfile import uuid from types import SimpleNamespace import httpx import pytest +from fastapi import HTTPException from app.api import skills as skills_api -from app.core.security import get_current_user +from app.core.security import get_current_admin, get_current_user from app.main import app @@ -23,13 +26,19 @@ def __iter__(self): class FakeSession: - def __init__(self, *, skill=None): + def __init__(self, *, skill=None, execute_results=None, commit_error=None): self.skill = skill + self.execute_results = list(execute_results) if execute_results is not None else None + self.commit_error = commit_error self.added = [] self.deleted = [] self.committed = False async def execute(self, _query): + if self.execute_results is not None: + if not self.execute_results: + raise AssertionError("unexpected extra execute() call") + return FakeScalarResult(self.execute_results.pop(0)) return FakeScalarResult(self.skill) def add(self, value): @@ -42,6 +51,8 @@ async def delete(self, value): self.deleted.append(value) async def commit(self): + if self.commit_error is not None: + raise self.commit_error self.committed = True @@ -206,3 +217,241 @@ async def test_browse_write_creates_tenant_skill_without_iterating_lazy_files( assert created_file.path == "SKILL.md" assert created_file.content == "# test" assert session.committed is True + + +class FakeSkillFile: + def __init__(self, path: str, content: str): + self.path = path + self.content = content + + +def _zip_bytes(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for path, content in files.items(): + archive.writestr(path, content) + return buffer.getvalue() + + +@pytest.mark.asyncio +async def test_preview_folder_upload_reports_existing_registry_diff(monkeypatch, platform_admin_user): + existing_skill = SimpleNamespace( + id=uuid.uuid4(), + folder_name="demo-skill", + name="Demo", + description="old", + icon="📋", + category="general", + tenant_id=platform_admin_user.tenant_id, + files=[FakeSkillFile("SKILL.md", "# Old\n"), FakeSkillFile("stale.txt", "remove\n")], + ) + session = FakeSession(skill=existing_skill) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes({"demo-skill/SKILL.md": b"# New\n", "demo-skill/scripts/run.py": b"print(\'ok\')\n"}) + result = await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + assert result["mode"] == "update" + assert result["deleted_paths"] == ["stale.txt"] + + +@pytest.mark.asyncio +async def test_apply_folder_upload_replaces_registry_files(monkeypatch, platform_admin_user): + existing_skill = SimpleNamespace( + id=uuid.uuid4(), + folder_name="demo-skill", + name="Demo", + description="old", + icon="📋", + category="general", + tenant_id=platform_admin_user.tenant_id, + files=[FakeSkillFile("SKILL.md", "# Old\n"), FakeSkillFile("stale.txt", "remove\n")], + ) + session = FakeSession(skill=existing_skill) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes( + { + "demo-skill/SKILL.md": ( + b"---\nname: Uploaded Demo\n" + b"description: updated description\n" + b"icon: \xf0\x9f\x9a\x80\n" + b"category: automation\n" + b"---\n\n# New\n" + ), + "demo-skill/scripts/run.py": b"print(\'ok\')\n", + } + ) + preview = await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + result = await skills_api.apply_folder_upload_from_archive( + archive, + target_folder="demo-skill", + expected_digest=preview["digest"], + expected_target_state_digest=preview["target_state_digest"], + replace_confirmed=True, + current_user=platform_admin_user, + ) + + assert result["mode"] == "update" + assert result["files_written"] == 2 + assert result["deleted_count"] == 1 + assert existing_skill.name == "Uploaded Demo" + assert existing_skill.description == "updated description" + assert existing_skill.icon == "🚀" + assert existing_skill.category == "automation" + assert session.deleted == existing_skill.files + written_files = [value for value in session.added if isinstance(value, skills_api.SkillFile)] + assert [(value.path, value.content) for value in written_files] == [ + ( + "SKILL.md", + "---\nname: Uploaded Demo\n" + "description: updated description\n" + "icon: 🚀\n" + "category: automation\n" + "---\n\n# New\n", + ), + ("scripts/run.py", "print(\'ok\')\n"), + ] + assert all(value.path != "stale.txt" for value in written_files) + assert session.committed is True + + +@pytest.mark.asyncio +async def test_preview_folder_upload_rejects_missing_root_skill_md(monkeypatch, platform_admin_user): + session = FakeSession(skill=None) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes({"demo-skill/readme.md": b"missing\n"}) + + with pytest.raises(HTTPException) as exc: + await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_preview_folder_upload_rejects_invalid_target_folder(monkeypatch, platform_admin_user): + session = FakeSession(skill=None) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes({"demo-skill/SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException) as exc: + await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="bad/name", + current_user=platform_admin_user, + ) + + assert exc.value.status_code == 400 + assert "folder" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_preview_folder_upload_rejects_hidden_folder_conflict(monkeypatch, platform_admin_user): + hidden_skill = SimpleNamespace( + id=uuid.uuid4(), + folder_name="demo-skill", + name="Hidden Demo", + tenant_id=uuid.uuid4(), + files=[], + ) + session = FakeSession(execute_results=[None, hidden_skill]) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes({"demo-skill/SKILL.md": b"# Demo\n"}) + + with pytest.raises(HTTPException) as exc: + await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + assert exc.value.status_code == 409 + assert "folder" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_preview_folder_upload_rejects_uploaded_name_conflict(monkeypatch, platform_admin_user): + conflicting_skill = SimpleNamespace( + id=uuid.uuid4(), + folder_name="other-skill", + name="Uploaded Demo", + tenant_id=platform_admin_user.tenant_id, + files=[], + ) + session = FakeSession(execute_results=[None, None, conflicting_skill]) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + archive = _zip_bytes( + { + "demo-skill/SKILL.md": ( + b"---\nname: Uploaded Demo\ndescription: conflict\n---\n\n# Demo\n" + ), + } + ) + + with pytest.raises(HTTPException) as exc: + await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + assert exc.value.status_code == 409 + assert "name" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_preview_folder_upload_rejects_registry_skill_over_size_limit(monkeypatch, platform_admin_user): + session = FakeSession(execute_results=[None, None, None]) + monkeypatch.setattr(skills_api, "async_session", FakeAsyncSessionFactory(session)) + + oversized = "a" * (skills_api.MAX_SKILL_SIZE + 1) + archive = _zip_bytes({"demo-skill/SKILL.md": oversized.encode("utf-8")}) + + with pytest.raises(HTTPException) as exc: + await skills_api.preview_folder_upload_from_archive( + archive, + target_folder="demo-skill", + current_user=platform_admin_user, + ) + + assert exc.value.status_code == 413 + + +@pytest.mark.asyncio +async def test_upload_folder_apply_requires_target_state_digest_field(client, monkeypatch, platform_admin_user): + async def _should_not_run(**_kwargs): + raise AssertionError("handler should not run when required form field is missing") + + monkeypatch.setattr(skills_api, "apply_folder_upload_from_archive", _should_not_run) + app.dependency_overrides[get_current_admin] = lambda: platform_admin_user + + files = {"file": ("demo-skill.zip", _zip_bytes({"demo-skill/SKILL.md": b"# Demo\n"}), "application/zip")} + data = { + "target_folder": "demo-skill", + "expected_digest": "digest", + "replace_confirmed": "true", + } + + async with await client() as ac: + response = await ac.post("/api/skills/upload-folder/apply", data=data, files=files) + + app.dependency_overrides.clear() + + assert response.status_code == 422 diff --git a/backend/tests/test_ss_local_proxy_startup.py b/backend/tests/test_ss_local_proxy_startup.py new file mode 100644 index 000000000..39a122c5f --- /dev/null +++ b/backend/tests/test_ss_local_proxy_startup.py @@ -0,0 +1,54 @@ +import asyncio +from pathlib import Path +import shutil + +from app import main +import pytest + + +def test_load_ss_nodes_from_config_skips_directory(tmp_path: Path): + cfg_dir = tmp_path / "ss-nodes.json" + cfg_dir.mkdir() + + nodes = main._load_ss_nodes_from_config(str(cfg_dir)) + + assert nodes is None + + +def test_load_ss_nodes_from_config_reads_valid_json(tmp_path: Path): + cfg_file = tmp_path / "ss-nodes.json" + cfg_file.write_text( + '[{"server":"1.2.3.4","port":1080,"password":"secret","method":"chacha20-ietf-poly1305","label":"test"}]' + ) + + nodes = main._load_ss_nodes_from_config(str(cfg_file)) + + assert nodes == [ + { + "server": "1.2.3.4", + "port": 1080, + "password": "secret", + "method": "chacha20-ietf-poly1305", + "label": "test", + } + ] + + +@pytest.mark.asyncio +async def test_start_ss_local_skips_env_fallback_when_invalid_config_exists( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + cfg_dir = tmp_path / "ss-nodes.json" + cfg_dir.mkdir() + + async def fail_if_called(*args, **kwargs): + raise AssertionError("ss-local should not start when the config path exists but is invalid") + + monkeypatch.setenv("SS_CONFIG_FILE", str(cfg_dir)) + monkeypatch.setenv("SS_SERVER", "1.2.3.4") + monkeypatch.setenv("SS_PASSWORD", "secret") + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/ss-local" if name == "ss-local" else None) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fail_if_called) + + await main._start_ss_local() diff --git a/backend/tests/test_websocket_retry.py b/backend/tests/test_websocket_retry.py new file mode 100644 index 000000000..ce52aea96 --- /dev/null +++ b/backend/tests/test_websocket_retry.py @@ -0,0 +1,101 @@ +import uuid +from types import SimpleNamespace + +import pytest + +from app.api.websocket import ( + _find_retry_anchor_message, + _finalize_onboarding_progress_if_needed, +) + + +def _msg(role: str, *, hidden: bool = False): + return SimpleNamespace( + id=uuid.uuid4(), + role=role, + is_hidden=hidden, + content=f"{role}-content", + ) + + +def test_retry_anchor_defaults_to_latest_visible_user_message(): + hidden = _msg("user", hidden=True) + user = _msg("user") + assistant = _msg("assistant") + + anchor = _find_retry_anchor_message([hidden, user, assistant]) + + assert anchor is user + + +def test_retry_anchor_uses_preceding_user_for_assistant_message_id(): + first_user = _msg("user") + assistant = _msg("assistant") + tool = _msg("tool_call") + + anchor = _find_retry_anchor_message( + [first_user, assistant, tool], + str(tool.id), + ) + + assert anchor is first_user + + +def test_retry_anchor_returns_none_for_unknown_message_id(): + user = _msg("user") + assistant = _msg("assistant") + + anchor = _find_retry_anchor_message( + [user, assistant], + str(uuid.uuid4()), + ) + + assert anchor is None + + +class _DummyAsyncSession: + def __init__(self): + self.db = object() + + async def __aenter__(self): + return self.db + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _DummyWebSocket: + def __init__(self): + self.messages = [] + + async def send_json(self, payload): + self.messages.append(payload) + + +@pytest.mark.asyncio +async def test_finalize_onboarding_progress_marks_completed_when_no_chunk_fired(monkeypatch): + calls = [] + session = _DummyAsyncSession() + + async def _fake_mark_onboarding_phase(db, agent_id, user_id, phase): + calls.append((db, agent_id, user_id, phase)) + + monkeypatch.setattr("app.api.websocket.async_session", lambda: session) + monkeypatch.setattr("app.services.onboarding.mark_onboarding_phase", _fake_mark_onboarding_phase) + + websocket = _DummyWebSocket() + agent_id = uuid.uuid4() + user_id = uuid.uuid4() + + marked = await _finalize_onboarding_progress_if_needed( + needs_onboarding_mark=True, + onboarding_mark_done=False, + agent_id=agent_id, + user_id=user_id, + onboarding_target_phase="greeted", + websocket=websocket, + ) + + assert marked is True + assert calls == [(session.db, agent_id, user_id, "greeted")] + assert websocket.messages == [{"type": "onboarded", "agent_id": str(agent_id)}] diff --git a/backend/tests/test_websocket_title_and_edit_helpers.py b/backend/tests/test_websocket_title_and_edit_helpers.py new file mode 100644 index 000000000..912e4faad --- /dev/null +++ b/backend/tests/test_websocket_title_and_edit_helpers.py @@ -0,0 +1,20 @@ +from app.api.websocket import _rewrite_edited_user_content, _should_generate_session_title + + +def test_should_generate_session_title_only_for_first_real_turn(): + assert _should_generate_session_title(True, False, False) is True + assert _should_generate_session_title(False, False, False) is False + assert _should_generate_session_title(True, True, False) is False + assert _should_generate_session_title(True, False, True) is False + + +def test_rewrite_edited_user_content_preserves_file_and_image_wrappers(): + original = "[file:cat.png]\n[image_data:data:image/png;base64,abc123]\nOriginal prompt" + rewritten = _rewrite_edited_user_content(original, "New prompt") + assert rewritten == "[file:cat.png]\n[image_data:data:image/png;base64,abc123]\nNew prompt" + + +def test_rewrite_edited_user_content_preserves_attachment_banner_lines(): + original = "[file:notes.txt]\n[Attachment: notes.txt]\nOriginal question" + rewritten = _rewrite_edited_user_content(original, "Updated question") + assert rewritten == "[file:notes.txt]\n[Attachment: notes.txt]\nUpdated question" diff --git a/backend/tests/test_workspace_api.py b/backend/tests/test_workspace_api.py new file mode 100644 index 000000000..30381be16 --- /dev/null +++ b/backend/tests/test_workspace_api.py @@ -0,0 +1,92 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.api import workspace as workspace_api + + +class DummyResult: + def __init__(self, values=None): + self._values = list(values or []) + + def scalar_one_or_none(self): + return self._values[0] if self._values else None + + def scalars(self): + return self + + def all(self): + return list(self._values) + + +class RecordingDB: + def __init__(self, responses=None): + self.responses = list(responses or []) + self.statements = [] + + async def execute(self, statement): + self.statements.append(str(statement)) + return self.responses.pop(0) if self.responses else DummyResult() + + +@pytest.mark.asyncio +async def test_list_projects_org_admin_is_tenant_scoped(): + tenant_id = uuid.uuid4() + current_user = SimpleNamespace(role="org_admin", tenant_id=tenant_id, identity=None) + db = RecordingDB([DummyResult([])]) + + await workspace_api.list_projects(current_user=current_user, db=db) + + assert "workspace_projects.tenant_id =" in db.statements[0] + + +@pytest.mark.asyncio +async def test_list_projects_platform_admin_is_not_tenant_scoped(): + current_user = SimpleNamespace(role="platform_admin", tenant_id=uuid.uuid4(), identity=None) + db = RecordingDB([DummyResult([])]) + + await workspace_api.list_projects(current_user=current_user, db=db) + + assert "workspace_projects.tenant_id =" not in db.statements[0] + + +@pytest.mark.asyncio +async def test_approve_deploy_passes_tenant_scope_for_org_admin(monkeypatch): + tenant_id = uuid.uuid4() + current_user = SimpleNamespace(role="org_admin", tenant_id=tenant_id, identity=None) + mock_approve = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(workspace_api, "approve_container_deploy", mock_approve) + + await workspace_api.approve_deploy( + slug="demo", + body=workspace_api.ApproveRequest(memory="256m"), + current_user=current_user, + ) + + mock_approve.assert_awaited_once_with( + "demo", + {"memory": "256m"}, + tenant_id=tenant_id, + include_platform_global=False, + ) + + +@pytest.mark.asyncio +async def test_reject_deploy_passes_global_scope_for_platform_admin(monkeypatch): + current_user = SimpleNamespace( + role="platform_admin", + tenant_id=uuid.uuid4(), + identity=SimpleNamespace(is_platform_admin=True), + ) + mock_reject = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(workspace_api, "reject_container_deploy", mock_reject) + + await workspace_api.reject_deploy(slug="demo", current_user=current_user) + + mock_reject.assert_awaited_once_with( + "demo", + tenant_id=None, + include_platform_global=True, + ) diff --git a/backend/tests/test_workspace_tools.py b/backend/tests/test_workspace_tools.py new file mode 100644 index 000000000..3dfd84b72 --- /dev/null +++ b/backend/tests/test_workspace_tools.py @@ -0,0 +1,169 @@ +import uuid +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.services import workspace_tools +from app.services.workspace_tools import _resolve_project_dockerfile + + +class RecordingSession: + def __init__(self, execute_result=None): + self.execute_result = execute_result + self.added = [] + self.committed = False + + async def execute(self, _query): + return self.execute_result + + def add(self, project): + self.added.append(project) + + async def commit(self): + self.committed = True + + +class SessionContext: + def __init__(self, session: RecordingSession): + self.session = session + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class ScalarsResult: + def __init__(self, values): + self.values = values + + def scalars(self): + return self + + def all(self): + return self.values + + +def test_resolve_project_dockerfile_prefers_stored_path(tmp_path: Path): + agent_ws = tmp_path / "agent" + primary = agent_ws / "workspace" / "my-app" / "Dockerfile" + stray = agent_ws / "workspace" / "other" / "Dockerfile" + primary.parent.mkdir(parents=True, exist_ok=True) + stray.parent.mkdir(parents=True, exist_ok=True) + primary.write_text("FROM python:3.12\n") + stray.write_text("FROM node:20\n") + + project = SimpleNamespace(dockerfile_path="workspace/my-app/Dockerfile") + + resolved = _resolve_project_dockerfile(agent_ws, "demo", project) + + assert resolved == primary + + +def test_resolve_project_dockerfile_rejects_ambiguous_fallback(tmp_path: Path): + agent_ws = tmp_path / "agent" + first = agent_ws / "workspace" / "demo" / "api" / "Dockerfile" + second = agent_ws / "workspace" / "demo" / "worker" / "Dockerfile" + first.parent.mkdir(parents=True, exist_ok=True) + second.parent.mkdir(parents=True, exist_ok=True) + first.write_text("FROM python:3.12\n") + second.write_text("FROM node:20\n") + + project = SimpleNamespace(dockerfile_path=None) + + with pytest.raises(ValueError, match="Multiple Dockerfiles found"): + _resolve_project_dockerfile(agent_ws, "demo", project) + + +@pytest.mark.asyncio +async def test_tool_request_build_persists_agent_owned_request(monkeypatch): + session = RecordingSession() + agent_id = uuid.uuid4() + tenant_id = uuid.uuid4() + monkeypatch.setattr(workspace_tools, "async_session", lambda: SessionContext(session)) + monkeypatch.setattr(workspace_tools, "check_slug_available", AsyncMock(return_value=None)) + monkeypatch.setattr(workspace_tools, "_get_agent_tenant_id", AsyncMock(return_value=tenant_id)) + + response = await workspace_tools.tool_request_build( + agent_id, + {"slug": "demo-app", "name": "Demo App", "description": "Tenant-aware build request"}, + ) + + assert response == ( + "Build request created!\n" + "- Slug: demo-app\n" + "- Name: Demo App\n" + "- Description: Tenant-aware build request\n" + "The Software Engineer agent will pick this up." + ) + assert session.committed is True + assert len(session.added) == 1 + project = session.added[0] + assert project.slug == "demo-app" + assert project.name == "Demo App" + assert project.description == "Tenant-aware build request" + assert project.tenant_id == tenant_id + assert project.requested_by == agent_id + assert project.status == "requested" + + +@pytest.mark.asyncio +async def test_tool_request_build_human_defaults_requester(monkeypatch): + session = RecordingSession() + monkeypatch.setattr(workspace_tools, "async_session", lambda: SessionContext(session)) + monkeypatch.setattr(workspace_tools, "check_slug_available", AsyncMock(return_value=None)) + + response = await workspace_tools.tool_request_build_human( + {"slug": "demo-app", "name": "Demo App", "description": "Human request", "requester": " "} + ) + + assert response == "Build request 'Demo App' created for slug 'demo-app'." + assert session.committed is True + assert len(session.added) == 1 + project = session.added[0] + assert project.slug == "demo-app" + assert project.name == "Demo App" + assert project.description == "Human request" + assert project.requested_by_human == "Frank" + assert project.status == "requested" + + +@pytest.mark.asyncio +async def test_tool_list_build_requests_formats_human_and_agent_requesters(monkeypatch): + agent_id = uuid.uuid4() + session = RecordingSession( + ScalarsResult( + [ + SimpleNamespace( + slug="human-app", + name="Human App", + description="Requested from dashboard", + requested_by_human="Avery", + requested_by=None, + ), + SimpleNamespace( + slug="agent-app", + name="Agent App", + description="Requested from agent", + requested_by_human=None, + requested_by=agent_id, + ), + ] + ) + ) + monkeypatch.setattr(workspace_tools, "async_session", lambda: SessionContext(session)) + + response = await workspace_tools.tool_list_build_requests() + + assert response == ( + "Pending build requests:\n\n" + "- [human-app] Human App\n" + " Requested by: Avery\n" + " Description: Requested from dashboard\n\n" + f"- [agent-app] Agent App\n" + f" Requested by: Agent {agent_id}\n" + " Description: Requested from agent\n" + ) diff --git a/backend/update_schema.py b/backend/update_schema.py index 1ccbe08e0..1e7fc2f4a 100644 --- a/backend/update_schema.py +++ b/backend/update_schema.py @@ -1,9 +1,10 @@ import asyncio -import json + from app.db.session import async_session -from sqlalchemy import select, update +from sqlalchemy import select from app.models.plugin_tool import PluginTool + async def main(): async with async_session() as db: res = await db.execute(select(PluginTool).where(PluginTool.name == 'agentbay_computer_screenshot')) diff --git a/docker-compose.yml b/docker-compose.yml index 7c5ae9229..43cb8338b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: CORS_ORIGINS: '["*"]' FEISHU_APP_ID: ${FEISHU_APP_ID:-} FEISHU_APP_SECRET: ${FEISHU_APP_SECRET:-} - DOCKER_NETWORK: clawith_yaojin_network + DOCKER_NETWORK: clawith_network SS_CONFIG_FILE: /data/ss-nodes.json # Public base URL for constructing OAuth callback URLs and email links. # Required when deployed behind a reverse proxy (e.g. Nginx, Cloudflare). @@ -60,7 +60,6 @@ services: - ./backend/agent_data:/data/agents - /var/run/docker.sock:/var/run/docker.sock - ./ss-nodes.json:/data/ss-nodes.json:ro - privileged: true cap_add: - SYS_ADMIN security_opt: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..69202b79b --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Branding values are read at frontend build time. +# Set these before `npm run build` or before building the frontend Docker image. +# Changing nginx container runtime env alone will not update the already-built HTML metadata. +VITE_APP_NAME=Clawith +VITE_APP_DESCRIPTION=Clawith — 企业数字员工平台 +VITE_APP_LOGO_LIGHT=/logo-black.png +VITE_APP_LOGO_DARK=/logo-white.png diff --git a/frontend/index.html b/frontend/index.html index eba398a27..ab5be89ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,8 +5,8 @@ - - Clawith + + __APP_NAME__ @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0020452c..62b4b7a03 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "esbuild": "^0.28.0", "typescript": "^5.0.0", "vite": "^6.0.0" } @@ -320,9 +321,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -337,9 +338,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -354,9 +355,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -371,9 +372,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -388,9 +389,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -405,9 +406,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -422,9 +423,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -439,9 +440,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -456,9 +457,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -473,9 +474,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -490,9 +491,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -507,9 +508,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -524,9 +525,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -541,9 +542,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -558,9 +559,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -575,9 +576,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -592,9 +593,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -609,9 +610,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -626,9 +627,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -643,9 +644,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -660,9 +661,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -677,9 +678,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -694,9 +695,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -711,9 +712,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -728,9 +729,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -745,9 +746,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -1780,9 +1781,9 @@ ] }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1793,32 +1794,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -2661,6 +2662,490 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8bdc7c42c..60fc71d98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test tests/*.test.mjs", "build": "tsc && vite build", "preview": "vite preview" }, @@ -26,6 +27,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "esbuild": "^0.28.0", "typescript": "^5.0.0", "vite": "^6.0.0" } diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index d184fa01c..2cf12b433 100644 --- a/frontend/src/components/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser.tsx @@ -25,6 +25,8 @@ export interface FileBrowserApi { write: (path: string, content: string) => Promise; delete: (path: string) => Promise; upload?: (file: File, path: string, onProgress?: (pct: number) => void) => Promise; + previewZip?: (file: File) => Promise<{ root_folder: string; files: string[]; total: number }>; + extractZip?: (file: File, targetPath: string, rootName: string) => Promise<{ extracted: number }>; downloadUrl?: (path: string) => string; } @@ -38,6 +40,7 @@ export interface FileBrowserProps { edit?: boolean; delete?: boolean; directoryNavigation?: boolean; + zipImport?: boolean; }; fileFilter?: string[]; singleFile?: string; @@ -45,6 +48,8 @@ export interface FileBrowserProps { title?: string; readOnly?: boolean; onRefresh?: () => void; + onPathChange?: (path: string) => void; + refreshToken?: number; } // ─── Text file detection ─────────────────────────────── @@ -73,10 +78,12 @@ export default function FileBrowser({ features = {}, fileFilter, singleFile, - uploadAccept = '.pdf,.docx,.xlsx,.pptx,.txt,.md,.csv,.json,.xml,.yaml,.yml,.js,.ts,.py,.html,.css,.sh,.log,.png,.jpg,.jpeg,.gif,.svg,.webp', + uploadAccept = '.pdf,.docx,.xlsx,.pptx,.txt,.md,.csv,.json,.xml,.yaml,.yml,.js,.ts,.py,.html,.css,.sh,.log,.png,.jpg,.jpeg,.gif,.svg,.webp,.bmp,.ico', title, readOnly = false, onRefresh, + onPathChange, + refreshToken, }: FileBrowserProps) { const { t } = useTranslation(); const { @@ -86,10 +93,18 @@ export default function FileBrowser({ edit = !readOnly, delete: canDelete = !readOnly, directoryNavigation = false, + zipImport = false, } = features; // ─── State ───────────────────────────────────────── - const [currentPath, setCurrentPath] = useState(rootPath); + const [currentPath, setCurrentPathRaw] = useState(rootPath); + const setCurrentPath = useCallback((path: string) => { + setCurrentPathRaw(path); + onPathChange?.(path); + }, [onPathChange]); + + // Fire onPathChange on mount so parent starts in sync + useEffect(() => { onPathChange?.(rootPath); }, [rootPath, onPathChange]); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [contentLoaded, setContentLoaded] = useState(false); @@ -103,6 +118,11 @@ export default function FileBrowser({ const [promptModal, setPromptModal] = useState<{ title: string; placeholder: string; action: string } | null>(null); const [promptValue, setPromptValue] = useState(''); const [uploadProgress, setUploadProgress] = useState<{ fileName: string; percent: number } | null>(null); + const [showZipModal, setShowZipModal] = useState(false); + const [zipFile, setZipFile] = useState(null); + const [zipPreview, setZipPreview] = useState<{ root_folder: string; files: string[]; total: number } | null>(null); + const [zipRootName, setZipRootName] = useState(''); + const [zipUploading, setZipUploading] = useState(false); const textareaRef = useRef(null); @@ -150,7 +170,6 @@ export default function FileBrowser({ setLoading(false); }, [api, currentPath, singleFile, fileFilter]); - // ─── Drag-and-drop upload ───────────────────── const handleDroppedFiles = useCallback(async (files: File[]) => { if (!api.upload || files.length === 0) return; try { @@ -161,7 +180,7 @@ export default function FileBrowser({ }); } setUploadProgress(null); - reload(); + await reload(); onRefresh?.(); showToast(t('agent.upload.success', 'Upload successful')); } catch (err: any) { @@ -176,7 +195,7 @@ export default function FileBrowser({ accept: uploadAccept, }); - useEffect(() => { reload(); }, [reload]); + useEffect(() => { reload(); }, [reload, refreshToken]); // ─── Load file content when viewing ─────────────── @@ -280,6 +299,51 @@ export default function FileBrowser({ } }; + const closeZipModal = useCallback(() => { + setShowZipModal(false); + setZipFile(null); + setZipPreview(null); + setZipRootName(''); + setZipUploading(false); + }, []); + + const zipTargetPath = useCallback(() => { + const normalizedRoot = rootPath.replace(/^\/+|\/+$/g, ''); + const normalizedCurrent = currentPath.replace(/^\/+|\/+$/g, ''); + if (!normalizedRoot) return normalizedCurrent; + if (normalizedCurrent === normalizedRoot) return ''; + if (normalizedCurrent.startsWith(`${normalizedRoot}/`)) return normalizedCurrent.slice(normalizedRoot.length + 1); + return normalizedCurrent; + }, [currentPath, rootPath]); + + const handleZipFileChange = useCallback(async (file?: File | null) => { + if (!file || !api.previewZip) return; + setZipFile(file); + try { + const preview = await api.previewZip(file); + setZipPreview(preview); + setZipRootName(preview.root_folder || ''); + } catch (err: any) { + showToast(`Failed to preview zip: ${err?.message || err}`, 'error'); + } + }, [api, showToast]); + + const handleZipExtract = useCallback(async () => { + if (!zipFile || !api.extractZip) return; + setZipUploading(true); + try { + const result = await api.extractZip(zipFile, zipTargetPath(), zipRootName); + showToast(`Extracted ${result.extracted} files successfully`); + closeZipModal(); + await reload(); + onRefresh?.(); + } catch (err: any) { + showToast(`Failed to extract: ${err?.message || err}`, 'error'); + } finally { + setZipUploading(false); + } + }, [api, zipFile, zipRootName, zipTargetPath, closeZipModal, reload, onRefresh, showToast]); + // ─── Breadcrumbs ────────────────────────────────── const pathParts = currentPath ? currentPath.split('/').filter(Boolean) : []; @@ -373,6 +437,77 @@ export default function FileBrowser({ ); }; + const renderZipModal = () => { + if (!showZipModal) return null; + return ( +
{ if (e.target === e.currentTarget) closeZipModal(); }} + > +
+
+

Import Skill Package

+ +
+ + {!zipPreview ? ( +
+

+ Select a .zip file containing skill definitions (.md files with YAML frontmatter). +

+ void handleZipFileChange(e.target.files?.[0])} + /> +
+ ) : ( +
+

+ {zipPreview.total} files found in zip +

+ +
+ + setZipRootName(e.target.value)} + placeholder="(empty = extract contents directly)" + style={{ width: '100%', fontSize: '13px', boxSizing: 'border-box' }} + /> +

+ Will extract to: {currentPath || rootPath || 'root'}{zipRootName ? `/${zipRootName}` : ''}... +

+
+ +
+ {zipPreview.files.slice(0, 20).map((file, i) => ( +
{file}
+ ))} + {zipPreview.total > 20 && ( +
+ ...and {zipPreview.total - 20} more files +
+ )} +
+ +
+ + +
+
+ )} +
+
+ ); + }; + // ═══════════════════════════════════════════════════ // SINGLE FILE MODE (Soul-style) // ═══════════════════════════════════════════════════ @@ -466,10 +601,10 @@ export default function FileBrowser({ ) : isImage(viewing) ? (
{api.downloadUrl ? ( - {viewing.split('/').pop()} ) : (
Cannot preview image without download URL
@@ -499,14 +634,12 @@ export default function FileBrowser({ // ═══════════════════════════════════════════════════ return (
- {/* Drop overlay */} {isDragging && (
{t('agent.workspace.dragOrClick', 'Drop files to upload')}
)} - {/* Toolbar */}
{title &&

{title}

} @@ -515,6 +648,20 @@ export default function FileBrowser({ {upload && api.upload && ( )} + {zipImport && api.previewZip && api.extractZip && ( + + )} {newFolder && (
); diff --git a/frontend/src/components/SkillAutocomplete.tsx b/frontend/src/components/SkillAutocomplete.tsx new file mode 100644 index 000000000..6d3339a18 --- /dev/null +++ b/frontend/src/components/SkillAutocomplete.tsx @@ -0,0 +1,244 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; + +interface SkillMapEntry { + name: string; + emoji?: string; + description?: string; +} + +interface SkillAutocompleteProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + skillMap: Record; + placeholder?: string; + className?: string; + disabled?: boolean; + inputRef?: React.RefObject; + onPaste?: React.ClipboardEventHandler; +} + +interface DropdownItem { + segment: string; + fullKey: string; + isLeaf: boolean; + name?: string; + emoji?: string; + description?: string; +} + +const MAX_ROWS = 10; +const LINE_HEIGHT = 20; // px per row +const PADDING_Y = 20; // top (10px) + bottom (10px) padding + +export default function SkillAutocomplete({ + value, + onChange, + onSubmit, + skillMap, + placeholder, + className, + disabled, + inputRef: externalRef, + onPaste, +}: SkillAutocompleteProps) { + const [showDropdown, setShowDropdown] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [items, setItems] = useState([]); + const internalRef = useRef(null); + const ref = externalRef || internalRef; + const dropdownRef = useRef(null); + + // Auto-resize textarea + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = 'auto'; + const maxHeight = LINE_HEIGHT * MAX_ROWS + PADDING_Y; + el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; + el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden'; + }, [value, ref]); + + useEffect(() => { + if (!skillMap || !value.startsWith('/')) { + setShowDropdown(false); + return; + } + + const keys = Object.keys(skillMap); + if (keys.length === 0) { + setShowDropdown(false); + return; + } + + const raw = value.slice(1); + const prefix = raw.endsWith(':') ? raw : raw.includes(':') ? raw.slice(0, raw.lastIndexOf(':') + 1) : ''; + const query = raw.endsWith(':') ? '' : (raw.includes(':') ? raw.slice(raw.lastIndexOf(':') + 1) : raw); + + const matching = keys.filter(k => k.startsWith(prefix)); + + const segmentMap = new Map(); + for (const key of matching) { + const rest = key.slice(prefix.length); + const nextColon = rest.indexOf(':'); + const segment = nextColon >= 0 ? rest.slice(0, nextColon) : rest; + + if (!segment) continue; + if (query && !segment.toLowerCase().includes(query.toLowerCase())) continue; + + if (!segmentMap.has(segment)) { + const fullKey = prefix + segment; + const isLeaf = fullKey in skillMap; + const entry = isLeaf ? skillMap[fullKey] : undefined; + const hasChildren = keys.some(k => k.startsWith(fullKey + ':')); + + segmentMap.set(segment, { + segment, + fullKey, + isLeaf, + name: entry?.name || segment, + emoji: entry?.emoji, + description: entry?.description || (hasChildren && !isLeaf ? 'Package' : undefined), + }); + } + } + + const filtered = Array.from(segmentMap.values()); + setItems(filtered); + setShowDropdown(filtered.length > 0); + setSelectedIndex(0); + }, [value, skillMap]); + + const hasChildren = useCallback((item: DropdownItem) => { + return Object.keys(skillMap).some(k => k.startsWith(item.fullKey + ':')); + }, [skillMap]); + + const selectItem = useCallback((item: DropdownItem) => { + if (item.isLeaf && !hasChildren(item)) { + onChange(`/${item.fullKey} `); + setShowDropdown(false); + } else { + onChange(`/${item.fullKey}:`); + } + }, [onChange, hasChildren]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (showDropdown) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, items.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + return; + } + if (e.key === 'Enter' && items[selectedIndex]) { + e.preventDefault(); + const item = items[selectedIndex]; + if (item.isLeaf) { + onChange(`/${item.fullKey} `); + setShowDropdown(false); + } else { + selectItem(item); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowDropdown(false); + return; + } + if (e.key === 'Tab' && items[selectedIndex]) { + e.preventDefault(); + selectItem(items[selectedIndex]); + return; + } + } + + // Enter without Shift submits; Shift+Enter inserts newline + if (e.key === 'Enter' && !e.shiftKey && !showDropdown) { + e.preventDefault(); + onSubmit(); + } + }; + + useEffect(() => { + if (showDropdown && dropdownRef.current) { + const el = dropdownRef.current.children[selectedIndex] as HTMLElement; + el?.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex, showDropdown]); + + return ( +
+ {showDropdown && ( +
e.preventDefault()} + style={{ + position: 'absolute', + bottom: '100%', + left: 0, + right: 0, + maxHeight: '240px', + overflowY: 'auto', + background: 'var(--bg-secondary, #1e1e1e)', + border: '1px solid var(--border-color, #333)', + borderRadius: '8px', + marginBottom: '4px', + zIndex: 100, + boxShadow: '0 -4px 12px rgba(0,0,0,0.3)', + }} + > + {items.map((item, i) => ( +
selectItem(item)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + background: i === selectedIndex ? 'var(--bg-hover, #2a2a2a)' : 'transparent', + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '14px', + }} + > + {item.emoji && {item.emoji}} + {item.segment} + {!item.isLeaf && } + {item.description && ( + + {item.description} + + )} +
+ ))} +
+ )} +