diff --git a/.github/workflows/ci-v2.yml b/.github/workflows/ci-v2.yml deleted file mode 100644 index bc14cdf9c2ba..000000000000 --- a/.github/workflows/ci-v2.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: CI v2 -on: - pull_request: - -permissions: - actions: read - contents: read - -concurrency: - group: ci-v2-${{ github.event.pull_request.number }} - cancel-in-progress: true - -env: - # turbo --affected diffs against this ref. CI checkouts have origin/ but no local main. - TURBO_SCM_BASE: origin/${{ github.base_ref }} - TURBO_TELEMETRY_DISABLED: "1" - -jobs: - changes: - runs-on: ubuntu-latest - if: github.repository == 'activepieces/activepieces' - outputs: - packages: ${{ steps.affected.outputs.packages }} - all: ${{ steps.affected.outputs.all }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Find affected packages - id: affected - run: | - set -euo pipefail - ALL=false - # Files outside any package (workflows, root config, tools) are invisible to turbo's - # graph, so a change there runs everything. Docs and markdown never do. - OUTSIDE_PACKAGES=$(git diff --name-only "$TURBO_SCM_BASE...HEAD" | grep -vE '^(packages/|docs/|brain/|\.agents/|\.claude/)|\.md$' || true) - if [ -n "$OUTSIDE_PACKAGES" ]; then - ALL=true - fi - # Pieces follow the old ci.yml rule: a piece runs when its own files changed, and every - # piece runs when pieces/framework or pieces/common changed. A change to a core package - # (core-piece-types, core-utils, ...) does not pull the 700+ pieces in, even though they - # depend on it; a break there is caught when pieces are published, not in the PR. - if ! DIRECT=$(npx --yes turbo@2.9.14 ls --filter="[$TURBO_SCM_BASE...HEAD]" --output=json | jq -c '[.packages.items[].name]') \ - || ! AFFECTED=$(npx --yes turbo@2.9.14 ls --affected --output=json | jq -c '[.packages.items[].name]'); then - ALL=true - PACKAGES='[]' - else - PACKAGES=$(jq -cn --argjson direct "$DIRECT" --argjson affected "$AFFECTED" ' - ($direct | any(. == "@activepieces/pieces-framework" or . == "@activepieces/pieces-common")) as $allPieces - | [$affected[] | select((startswith("@activepieces/piece-") | not) or $allPieces or IN($direct[]))]') - fi - echo "all=$ALL" >> "$GITHUB_OUTPUT" - echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" - echo "all=$ALL" - echo "packages=$PACKAGES" - - lint: - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup - with: - turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - - - name: Lint core packages - if: needs.changes.outputs.all == 'true' - run: npm run lint-core - - # Also on full runs: a PR that edits a workflow and pieces/framework in one go still needs - # every piece linted, and the changes job's list is what knows that. - - name: Lint affected packages - env: - AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} - run: | - set -euo pipefail - FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | "--filter=" + .' | tr '\n' ' ') - if [ -n "$FILTERS" ]; then - npx turbo run lint $FILTERS - fi - - build-test: - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' - services: - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - env: - AP_REDIS_HOST: localhost - AP_REDIS_PORT: "6379" - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - uses: ./.github/actions/setup - with: - turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - - # One turbo invocation so every package builds once: the affected packages from the changes - # job, plus the core set on full runs. --affected and --filter intersect in turbo, so the - # union is built here as explicit filters. api is excluded because its `test` script runs - # the three integration suites serially; the api-tests matrix covers those. - - name: Typecheck, build and test - env: - AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} - RUN_ALL: ${{ needs.changes.outputs.all }} - run: | - set -euo pipefail - FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | select(. != "api") | "--filter=" + .' | tr '\n' ' ') - if [ "$RUN_ALL" = "true" ]; then - FILTERS="$FILTERS $(npx tsx tools/scripts/test-filters.ts)" - fi - npx turbo run typecheck build test $FILTERS - - - name: Check new migrations are rollback-safe - run: npx tsx tools/scripts/check-migration-rollback.ts - - # The api e2e tests import the worker's source, and the worker resolves @activepieces/sandbox - # through its built dist, so the api jobs build worker too and also run when worker is affected. - api-tests: - name: api (${{ matrix.name }}) - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') || contains(needs.changes.outputs.packages, '"worker"') - strategy: - fail-fast: false - matrix: - include: - - name: ce - tasks: test-ce - - name: cloud - tasks: test-cloud - - name: ee, unit, migrations - tasks: test-ee test-unit check-migrations - services: - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - env: - # .env.tests pins AP_REDIS_HOST=redis, the alias inside the job's docker network, which a - # non-container job is not on. dotenv does not override values already in the environment. - AP_REDIS_HOST: localhost - AP_REDIS_PORT: "6379" - steps: - - uses: actions/checkout@v5 - - - uses: ./.github/actions/setup - with: - turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - - - run: npx turbo run build ${{ matrix.tasks }} --filter=api --filter=worker - - # Only the tool-search integration tests, against real PostgreSQL + pgvector. The api-tests - # matrix runs them on PGLITE, which papers over node-postgres driver behaviour. - tool-search-postgres: - name: tool-search (postgres) - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') - services: - postgres: - image: pgvector/pgvector:pg16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: activepieces - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - env: - AP_DB_TYPE: POSTGRES - AP_POSTGRES_HOST: localhost - AP_POSTGRES_PORT: "5432" - AP_POSTGRES_DATABASE: activepieces - AP_POSTGRES_USERNAME: postgres - AP_POSTGRES_PASSWORD: postgres - AP_POSTGRES_USE_SSL: "false" - steps: - - uses: actions/checkout@v5 - - - uses: ./.github/actions/setup - with: - turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - - - name: Enable pgvector extension - run: | - PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d activepieces \ - -c 'CREATE EXTENSION IF NOT EXISTS vector;' - - - run: npx turbo run build --filter=api - - # Serial: every file migrates the one shared service DB on DataSource.initialize(). - - name: Run tool-search integration tests on Postgres - working-directory: packages/server/api - run: npx vitest run test/integration/ce/tool-search --bail 1 --passWithNoTests=false --no-file-parallelism diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a7943960b93..84d743d86ff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,64 +6,92 @@ permissions: actions: read contents: read +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + # turbo --affected diffs against this ref. CI checkouts have origin/ but no local main. + TURBO_SCM_BASE: origin/${{ github.base_ref }} + TURBO_TELEMETRY_DISABLED: "1" jobs: - lint: + changes: runs-on: ubuntu-latest if: github.repository == 'activepieces/activepieces' + outputs: + packages: ${{ steps.affected.outputs.packages }} + all: ${{ steps.affected.outputs.all }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version: 24 + - name: Find affected packages + id: affected + run: | + set -euo pipefail + ALL=false + # Files outside any package (workflows, root config, tools) are invisible to turbo's + # graph, so a change there runs everything. Docs and markdown never do. + OUTSIDE_PACKAGES=$(git diff --name-only "$TURBO_SCM_BASE...HEAD" | grep -vE '^(packages/|docs/|brain/|\.agents/|\.claude/)|\.md$' || true) + if [ -n "$OUTSIDE_PACKAGES" ]; then + ALL=true + fi + # Pieces override the dependency graph: a piece runs when its own files changed, and every + # piece runs when pieces/framework or pieces/common changed. A change to a core package + # (core-piece-types, core-utils, ...) does not pull the 700+ pieces in, even though they + # depend on it; a break there is caught when pieces are published, not in the PR. + if ! DIRECT=$(npx --yes turbo@2.9.14 ls --filter="[$TURBO_SCM_BASE...HEAD]" --output=json | jq -c '[.packages.items[].name]') \ + || ! AFFECTED=$(npx --yes turbo@2.9.14 ls --affected --output=json | jq -c '[.packages.items[].name]'); then + ALL=true + PACKAGES='[]' + else + PACKAGES=$(jq -cn --argjson direct "$DIRECT" --argjson affected "$AFFECTED" ' + ($direct | any(. == "@activepieces/pieces-framework" or . == "@activepieces/pieces-common")) as $allPieces + | [$affected[] | select((startswith("@activepieces/piece-") | not) or $allPieces or IN($direct[]))]') + fi + echo "all=$ALL" >> "$GITHUB_OUTPUT" + echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" + echo "all=$ALL" + echo "packages=$PACKAGES" - - uses: oven-sh/setup-bun@v2 + lint: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' + steps: + - uses: actions/checkout@v5 with: - bun-version: latest + fetch-depth: 0 - - uses: actions/cache@v4 + - uses: ./.github/actions/setup with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - - run: bun install - - - name: Turbo Cache - uses: rharkor/caching-for-turbo@v2.4.2 - with: - provider: s3 - s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - s3-region: auto - s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - max-age: 3w - max-size: 20gb + - name: Lint core packages + if: needs.changes.outputs.all == 'true' + run: npm run lint-core - - name: Check if framework or common pieces are changed - id: check-framework-common + # Also on full runs: a PR that edits a workflow and pieces/framework in one go still needs + # every piece linted, and the changes job's list is what knows that. + - name: Lint affected packages + env: + AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} run: | - CHANGED_FILES="$(git diff --name-only HEAD origin/main | tr '\n' ' ')" - if echo "$CHANGED_FILES" | grep -q "pieces/framework\|pieces/common"; then - echo "framework_or_common_changed=true" >> $GITHUB_OUTPUT - else - echo "framework_or_common_changed=false" >> $GITHUB_OUTPUT + set -euo pipefail + FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | "--filter=" + .' | tr '\n' ' ') + if [ -n "$FILTERS" ]; then + npx turbo run lint $FILTERS fi - - name: Lint core projects - run: npm run lint-core - - - name: Lint all pieces projects - if: steps.check-framework-common.outputs.framework_or_common_changed == 'true' - run: npm run lint-pieces - - main: + build-test: runs-on: ubuntu-latest - if: github.repository == 'activepieces/activepieces' + needs: changes + if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]' services: redis: image: redis:7-alpine @@ -75,11 +103,6 @@ jobs: ports: - 6379:6379 env: - # packages/server/api/.env.tests pins AP_REDIS_HOST=redis, the alias the service container - # answers to inside the job's docker network, which a non-container job is not on. dotenv - # (vitest.setup.ts) does not override values already in the environment, so this wins and - # the tests reach the published port. AP_REDIS_HOST/PORT are in turbo.json - # globalPassThroughEnv; without that turbo's strict env mode would drop them. AP_REDIS_HOST: localhost AP_REDIS_PORT: "6379" steps: @@ -87,116 +110,83 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version: 24 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - uses: actions/cache@v4 + - uses: ./.github/actions/setup with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - run: bun install - - - name: Turbo Cache - uses: rharkor/caching-for-turbo@v2.4.2 - with: - provider: s3 - s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - s3-region: auto - s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - max-age: 3w - max-size: 20gb - - - name: Get changed files - id: changed-files - run: echo "files=$(git diff --name-only HEAD origin/main | tr '\n' ' ')" >> $GITHUB_OUTPUT - - - name: Check if framework or common pieces are changed - id: check-framework-common - run: | - CHANGED_FILES="${{ steps.changed-files.outputs.files }}" - if echo "$CHANGED_FILES" | grep -q "pieces/framework\|pieces/common"; then - echo "framework_or_common_changed=true" >> $GITHUB_OUTPUT - else - echo "framework_or_common_changed=false" >> $GITHUB_OUTPUT - fi - - - name: Extract pieces package names from changed files - id: extract-pieces - run: | - PIECES=$(echo "${{ steps.changed-files.outputs.files }}" | grep -oE "packages/pieces/(community|core)/[^/]+" | awk -F'/' '{print $NF}' | sort -u | tr '\n' ',' | sed 's/,$//') - if [ -n "$PIECES" ]; then - FILTERS=$(echo "$PIECES" | tr ',' '\n' | sed 's/.*/{&}/' | tr '\n' ',' | sed 's/,$//') - echo "pieces_filters=$FILTERS" >> $GITHUB_OUTPUT - else - echo "pieces_filters=" >> $GITHUB_OUTPUT - fi - - - name: Typecheck web - run: npx turbo run typecheck --filter=web - - - name: Build core projects - run: npx turbo run build --filter=web --filter=worker --filter=api --filter=@activepieces/engine --filter=@activepieces/piece-http --filter=@activepieces/piece-data-mapper --filter=@activepieces/piece-approval --filter=@activepieces/piece-webhook --filter=@activepieces/piece-delay - - - name: Build changed pieces projects - if: steps.extract-pieces.outputs.pieces_filters != '' && steps.check-framework-common.outputs.framework_or_common_changed == 'false' - run: | - IFS=',' read -ra PIECES <<< "${{ steps.extract-pieces.outputs.pieces_filters }}" - FILTER_ARGS="" - for piece in "${PIECES[@]}"; do - piece_name=$(echo "$piece" | tr -d '{}') - if [ -f "packages/pieces/community/${piece_name}/package.json" ] || [ -f "packages/pieces/core/${piece_name}/package.json" ]; then - FILTER_ARGS="$FILTER_ARGS --filter=@activepieces/piece-${piece_name}" - fi - done - if [ -n "$FILTER_ARGS" ]; then - npx turbo run build $FILTER_ARGS - fi - - - name: Build all pieces projects - if: steps.check-framework-common.outputs.framework_or_common_changed == 'true' - run: npx turbo run build --filter='@activepieces/piece-*' - - - name: Run all tests and migration checks in parallel + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} + + # One turbo invocation so every package builds once: the affected packages from the changes + # job, plus the core set on full runs. --affected and --filter intersect in turbo, so the + # union is built here as explicit filters. api is excluded because its `test` script runs + # the three integration suites serially; the api-tests matrix covers those. + - name: Typecheck, build and test + env: + AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }} + RUN_ALL: ${{ needs.changes.outputs.all }} run: | set -euo pipefail - pids=() + FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | select(. != "api") | "--filter=" + .' | tr '\n' ' ') + if [ "$RUN_ALL" = "true" ]; then + FILTERS="$FILTERS $(npx tsx tools/scripts/test-filters.ts)" + fi + npx turbo run typecheck build test $FILTERS - npx turbo run test $(npx tsx tools/scripts/test-filters.ts) & - pids+=($!) + - name: Check new migrations are rollback-safe + run: npx tsx tools/scripts/check-migration-rollback.ts - npx turbo run test-unit test-ce test-ee test-cloud check-migrations --filter=api & - pids+=($!) + # The api e2e tests import the worker's source, and the worker resolves @activepieces/sandbox + # through its built dist, so the api jobs build worker too and also run when worker is affected. + api-tests: + name: api (${{ matrix.name }}) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') || contains(needs.changes.outputs.packages, '"worker"') + strategy: + fail-fast: false + matrix: + include: + - name: ce + tasks: test-ce + - name: cloud + tasks: test-cloud + - name: ee, unit, migrations + tasks: test-ee test-unit check-migrations + services: + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + env: + # .env.tests pins AP_REDIS_HOST=redis, the alias inside the job's docker network, which a + # non-container job is not on. dotenv does not override values already in the environment. + AP_REDIS_HOST: localhost + AP_REDIS_PORT: "6379" + steps: + - uses: actions/checkout@v5 - npx tsx tools/scripts/check-migration-rollback.ts & - pids+=($!) + - uses: ./.github/actions/setup + with: + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - status=0 - for pid in "${pids[@]}"; do - if ! wait "$pid"; then - status=1 - fi - done - exit $status + - run: npx turbo run build ${{ matrix.tasks }} --filter=api --filter=worker - # Runs ONLY the tool-search integration tests against a real PostgreSQL + pgvector - # service container. The default `main` job above runs these on PGLITE, which papers - # over node-postgres-specific driver behaviour (e.g. TypeORM `.query()` returning - # `[rows, count]` instead of a plain rows array). This job exists to catch that class - # of bug. It is intentionally scoped and additive — it does NOT change how the main - # suite runs. + # Only the tool-search integration tests, against real PostgreSQL + pgvector. The api-tests + # matrix runs them on PGLITE, which papers over node-postgres driver behaviour. tool-search-postgres: name: tool-search (postgres) runs-on: ubuntu-latest - if: github.repository == 'activepieces/activepieces' + needs: changes + if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') services: postgres: image: pgvector/pgvector:pg16 @@ -212,8 +202,6 @@ jobs: ports: - 5432:5432 env: - # dotenv (vitest.setup.ts) loads .env.tests WITHOUT overriding values already - # present in the environment, so these job-level vars win over the PGLITE defaults. AP_DB_TYPE: POSTGRES AP_POSTGRES_HOST: localhost AP_POSTGRES_PORT: "5432" @@ -223,56 +211,22 @@ jobs: AP_POSTGRES_USE_SSL: "false" steps: - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v6 - with: - node-version: 24 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - run: bun install - - name: Turbo Cache - uses: rharkor/caching-for-turbo@v2.4.2 + - uses: ./.github/actions/setup with: - provider: s3 - s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} - s3-region: auto - s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} - s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} - s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - max-age: 3w - max-size: 20gb + turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }} + turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }} + turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }} + turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }} - name: Enable pgvector extension - # The pgvector image ships the extension binary but does not auto-create it in the - # database. The tool_search_index migration skips table creation when `vector` is - # absent, and the DB-only test never boots the server path that would CREATE it, - # so create it explicitly before migrations run. run: | PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d activepieces \ -c 'CREATE EXTENSION IF NOT EXISTS vector;' - - name: Build api - run: npx turbo run build --filter=api + - run: npx turbo run build --filter=api + # Serial: every file migrates the one shared service DB on DataSource.initialize(). - name: Run tool-search integration tests on Postgres working-directory: packages/server/api - # --no-file-parallelism: these files all point at the one shared `activepieces` - # service DB and each runs migrations on `DataSource.initialize()`. Vitest's default - # forked parallelism runs those migration passes concurrently, which collide on - # shared catalog objects (the migrations table, en_natural collation, added columns) - # and fail non-deterministically. Serial execution migrates once; later files skip - # via the migrations table. (PGLITE gives each file its own DB, so the default suite - # is unaffected — only this shared-Postgres job needs serializing.) run: npx vitest run test/integration/ce/tool-search --bail 1 --passWithNoTests=false --no-file-parallelism diff --git a/.github/workflows/warm-ci-cache.yml b/.github/workflows/warm-ci-cache.yml index 5ad0b7c67300..5068063aa56c 100644 --- a/.github/workflows/warm-ci-cache.yml +++ b/.github/workflows/warm-ci-cache.yml @@ -1,6 +1,6 @@ name: Warm CI cache # GitHub only lets a pull request restore caches created on the same PR or on the base branch. -# CI v2 runs on pull requests only, so this seeds the base-branch copies (bun downloads and the +# CI runs on pull requests only, so this seeds the base-branch copies (bun downloads and the # compiled redis-memory-server binary) that every new PR's first run restores. on: push: diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md index 988c63e0c765..b791144568ca 100644 --- a/brain/knowledge/ai-intelligence/ai-agents.md +++ b/brain/knowledge/ai-intelligence/ai-agents.md @@ -10,6 +10,16 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs - A step either carries its own configuration in the flow version's step settings — `settings.input` holds `agentTools`, `structuredOutput`, `prompt`, `maxSteps` and `aiProviderModel` (`{ provider, model, configId }`) — or stores the `externalId` of a saved **Agent** (`agent` table, `ee/agent/agent-entity.ts`) under `agentId` and carries nothing else. A linked step is resolved at run start, so improving the agent improves the next run of every flow using it. The two are exclusive: a step that sends both is refused. - Configured entirely in the Flow Builder (`web/src/app/builder/step-settings/agent-settings/`); a test panel runs a single agent step. `AgentTimeline` renders `AgentStepBlock[]` from the output as markdown blocks + expandable tool-call cards. +- **One engine, four doors.** Chat, agent chat, the agent builder and `run_agent` are not four systems. Each creates an `agent_conversation` row and runs the same `EXECUTE_AGENT_RUN` worker job, and the worker calls back over `getAgentConfig` (`ee/agent/rpc/agent-config-rpc.ts`) to ask which model, tools and instructions to use. The assistant chat is an agent turn with no agent row. Which tier list a door resolves against follows `namesItsOwnModel` in that RPC: FLOW_STEP and AGENT name their own model and read the flow tiers; CHAT and AGENT_BUILDER read the chat tiers. An agent therefore resolves the same model everywhere it is used: configured, chatted with, or dropped in a flow. + +| Door (`AgentRunSource`) | Model comes from | Stored as | +|---|---|---| +| `CHAT` | `conversation.modelName`, picked in the UI | tier id | +| `AGENT` | `agent.draft.modelName`; no picker is shown while chatting (`ai-chat-box.tsx` hides it when `agentId` is set) | concrete id | +| `AGENT_BUILDER` | `conversation.modelName` | tier id | +| `FLOW_STEP` | `agent.published.modelName`, or the step's own `input.aiProviderModel.model` | concrete id | + +Three things follow. `run_agent` has two modes: pointed at a saved agent (`agentId` set) the step sends no model and the agent row decides; inline, it sends `input.aiProviderModel.model`. An agent row carries two configs: agent-page chat reads `agent.draft`, a `run_agent` step reads `agent.published` (via `resolvePublishedAgent`), so the same agent can already run different models through different doors. The fast model crosses every door: `firstStepUsesFastModel` (`run-agent-turn.ts`) runs the first step of almost every turn on the fast tier, flow steps included, whatever the user picked. `execute-agent-run.ts` persists `config.tier.id` onto `conversation.modelName` when the job carried no `modelName`, so conversations already hold tier ids today (inline `run_agent`, eval runs, every `AGENT_BUILDER`), read back by `chat-tool-billing.ts`, `chat-analytics-sync.ts` and `pinnedModelOf`. ### Tool types (AgentTool discriminated union) @@ -20,6 +30,8 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs - **PredefinedInputsStructure** — per-field `AGENT_DECIDE` / `CHOOSE_YOURSELF` / `LEAVE_EMPTY` baked into the tool so the agent knows which inputs it controls. ### Gotchas +- **A chat turn's data lives in jsonb, so recording something per turn needs no migration.** `agent_conversation.messages` is a `jsonb` column and each entry is a `PersistedAgentMessageSchema`, which already carries optional per-turn fields (`thinkingDurationMs`, `feedback`). Adding another optional one is a zod field plus a `@activepieces/shared` patch bump — no column, no migration, no rollback flag. The conversation row itself only has `modelName`, which is why "record it on the conversation" is usually the wrong instinct: a user can switch tier mid-conversation, so anything that describes one turn belongs on the message, not the row. +- **But the server does not build those messages, so "no migration" does not mean "cheap".** `messages` arrives already assembled from the worker over RPC (`saveAgentMessages` in `rpc/conversation-rpc.ts`), and the server only persists it and bills off it. Putting a new per-turn field on a message therefore means changing the RPC request schema and the worker that fills it, not editing a server file. Costing this off the jsonb shape alone gave an estimate of about an hour for work that is really a change to the engine contract. Check who authors the object before you price a change to it. - **`CHAT_HIDDEN_TOOL_NAMES` is load-bearing for the MCP Activity feed, not just for chat UX.** The chat reaches the MCP server as an ordinary HTTP client, and `mcp_activity` records `ap_run_action`. The only thing keeping chat activity out of that feed is `ap_run_action` sitting in `CHAT_HIDDEN_TOOL_NAMES` (`core/shared/src/lib/ee/agent/tool-phases.ts`), filtered worker-side in `agent-mcp-client.ts`. Remove it and the chat starts writing rows into a tab meant for external MCP clients, with no test failing. See the MCP Server page for the enforcement that was designed and deliberately not built. - Gated by `platform.plan.agentsEnabled`; when off, the step type is hidden from the piece selector. Off by default on Community, on for Cloud plans that include it. diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index be0a26e5ebb4..6c9620816dea 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -12,9 +12,9 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - Supported providers (10): `openai`, `anthropic`, `google`, `azure`, `openrouter`, `bedrock`, `mistral`, `cloudflare-gateway`, `custom` (OpenAI-compatible, e.g. Ollama/LM Studio), `activepieces` (auto-provisioned via OpenRouter). ### How it works -- `GET /` list (auto-creates ACTIVEPIECES when `aiCreditsEnabled`); `GET /:provider/config` returns decrypted auth (engine-only); `GET /:provider/models` (cached); `POST /` create (validates creds first); `POST /:id` update; `DELETE /:id`. +- `GET /` list (auto-creates ACTIVEPIECES when `aiCreditsEnabled`); `GET /:provider/models` (cached); `POST /` create (validates creds first); `POST /:id` update; `DELETE /:id`. - For ACTIVEPIECES, update is blocked by an early return (only enabling `enabledForChat` is allowed); deletion is allowed and self-healing — `listProviders` recreates the managed row. -- Engine integration: AI pieces call `GET /v1/ai-providers/{provider}/config` on **every AI action execution** (no per-run caching), authorized by the engine token. +- Engine integration: the AI piece no longer fetches credentials. It POSTs `/v1/ai/execute`, the server enqueues an `EXECUTE_AI` worker job, and the worker resolves the provider over the `resolveAiProvider` RPC. `GET /:provider/config` was removed in [#15494](https://github.com/activepieces/activepieces/pull/15494). - For the managed ACTIVEPIECES provider that config route is also the **credit gate**: `assertCreditsAndAppSumoNotExceeded` (`platform/billing-provider.ts`) throws `QUOTA_EXCEEDED` when the Autumn credit or AppSumo balance is blocked — fires per AI call, but usage is only metered post-run, so in-flight spend is invisible to it. See decision 000016 (`brain/decisions/000016-managed-ai-metering-moves-to-centralized-worker-execution.md`). - Activepieces provisioning: `getOrCreateActivePiecesProviderAuthConfig()` → `enrichWithKeysIfNeeded()` mints an OpenRouter key. No system job is scheduled — renewal/top-up is driven by Autumn (`autoTopUps` in `autumn-billing.ts`), not Stripe. @@ -45,6 +45,23 @@ Prices are rounded to three decimals in the generator, both to kill float artefa among them — carry continuously floating five-decimal prices. Three decimals is below anything the UI renders and preserves every real price; the cheapest in the set is 0.01. +### Model tiers + +The published tier file, `https://cdn.activepieces.com/ai/pricing.json`, carries two lists: **flow tiers** +(`tiers`, `defaultTierId`, `fastTierId`) for the surfaces that name their own model (FLOW_STEP and AGENT), +and **chat tiers** (`chatTiers`, `chatDefaultTierId`, `chatFastTierId`) for CHAT and AGENT_BUILDER. The +chat keys are optional; when absent, chat reads the flow list. Each list must contain its own default and +fast tier and have unique ids. There are no cross-list rules: the same id may sit on both lists pointing at +different models, and a tier may exist on one list only. A file that fails any rule is rejected whole and +the release's bundled tiers serve instead. + +`modelTierCatalog.current(surface)` (`packages/server/utils/src/model-tier-catalog.ts`) is the single +accessor: synchronous, never throws, serves the cached file or `ACTIVEPIECES_CHAT_TIERS`, and refreshes in +the background every 15 minutes with a 5-minute back-off after a failure. `warmUp()` runs at API boot from +`aiProviderService.setup()`, bounded to 2 s so a dead CDN cannot block boot. Today only `warmUp()` calls it: the +resolution helpers in `ee/agent/agent-model-resolution.ts` (`findTier`, `resolveTier`, `FAST_TIER_ID`) still read +the bundled list and are the next thing to move. `AP_MODEL_TIERS_URL` overrides +the URL. The file is published from the console in `ap-analytics`; see decision 000041. ### Gotchas - **A catalog change takes up to ~2 days to reach a dropdown, through three caches in series.** CDN edge (`--cache-control max-age=3600`, 1h) → the server's in-memory catalog (`CATALOG_TTL_MS`, 24h) → `modelsCache` (flushed by the nightly `cron.schedule('0 0 * * *')`). So "I republished but the UI still shows the old price" is expected, not a bug; restarting the API short-circuits the two in-process layers. The edge TTL is deliberately 1h and not the 604800 that `publish-embed-sdk.yml` uses — that path is version-stamped, ours is a stable key rewritten in place, so a week-long edge cache would pin stale prices for a week. - **`Sync AI Model Catalog` is the only workflow that proves the CDN S3 credentials still work — a green `Publish Embed SDK` proves nothing.** Both read the same `CDN_S3_ACCESS_KEY_ID` / `CDN_S3_SECRET_ACCESS_KEY` / `CDN_S3_BUCKET` / `CDN_S3_ENDPOINT` secrets, but `publish-embed-sdk.yml` guards its upload job on `needs.check.outputs.publish == 'true'`, which is false whenever the version in `packages/ee/embed-sdk/package.json` is already on the CDN. Its daily cron then finishes in about nine seconds with the upload skipped and the run still marked success, so the key can rot for weeks behind a green tick — that is how the model-catalog cron came to fail with `InvalidAccessKeyId` on three consecutive Mondays while the embed SDK looked healthy. To date a dead key, find the last embed-SDK run whose `publish` job actually ran (`gh run list --workflow=publish-embed-sdk.yml`, look for a duration in minutes rather than seconds) and compare it against the first model-catalog failure. To test a rotated key, dispatch `sync-model-catalog.yml` — it uploads unconditionally. @@ -52,7 +69,19 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **No egress to the CDN means no model metadata, permanently and silently.** Air-gapped installs, networks with an outbound allowlist, and CDN outages all fall back to the plain `{ id, name }` row with no message explaining it — every metadata field is optional, so nothing throws. `AP_MODEL_CATALOG_URL` points at a self-hosted mirror and is the only fix. This is a knowing exception to `.claude/rules/self-hosting.md` (decision 000032), so treat "self-hoster says prices are missing" as a network question, not a bug. - **A new provider needs an entry in the generator's `MODELS_DEV_PROVIDER` map or it silently ships with no metadata.** models.dev provider ids do not match ours: `bedrock` → `amazon-bedrock`, `qwen` → `alibaba`, `moonshot` → `moonshotai`, `activepieces` → aliased onto `openrouter` at lookup time. The six OpenAI-compatible vendors were merged before the map was updated and produced exactly this — the run prints `no upstream source: …`, which is the thing to read after adding a provider. `cloudflare-gateway` and `custom` legitimately have no source. - **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all. +- **The pricing console shows bundled constants when no file is published, and says nothing.** `overview()` in `ai-pricing-service.ts` has three states, and two of them render the same numbers: **absent** (`NoSuchKey`) silently seeds the editor from `DEFAULT_PRICING_DRAFT`, the console's own checked-in copy of the tiers and weights, while **unreadable** (any other S3 error) shows those same constants *plus* a banner and blocks publishing. Only **present** is live data. So a screen full of plausible tiers is not evidence that anything was ever published — `curl -I https://cdn.activepieces.com/ai/pricing.json` is. The absent state is deliberate, it is how the first publish bootstraps, but it means "I can see the tiers" and "the prices are live" are unrelated facts. Worse, **absent and misconfigured are the same state**: `cdnClient.getJson` catches `NoSuchKey` and returns `undefined`, so a wrong bucket or an endpoint with the bucket in its path reads as "nothing published yet" and hides behind the defaults. The tell is the History tab — `list` does *not* catch `NoSuchKey`, so the same misconfiguration that reads as empty on the pricing page returns a 500 there. A 500 on history with a normal-looking pricing page means the S3 addressing is wrong, not that the history is empty. +- **Deleting the live tier file is safe for customers and confusing for everyone else.** `ai/pricing.json` was deleted on 2026-09-24 to drop a leftover test tier, and Activepieces kept serving the right tiers throughout: the 404 makes `fetchTiers` throw, the failure is logged and backed off, and `current()` keeps serving the last good copy the process fetched; only a process that never fetched one, a fresh boot during the outage, runs the `ACTIVEPIECES_CHAT_TIERS` shipped with the release. Two things do change. Each server logs 'Failed to refresh the AI model tiers file' (or 'Failed to load…' on a process that never got a copy) on every failed attempt, and attempts happen at boot and whenever `current()` is called with a stale or missing copy, at most once per five minutes (`FAILURE_BACKOFF_MS`), so a permanent 404 is retried the same way as a blip. Today only `warmUp()` calls it, so that is one line per boot. And the console reads the missing key as **absent**, so the next publish starts again at `version: 1` while the `ai/pricing/history/` objects from the old numbering are still there — the version number is `live.version + 1`, not a counter, so it is only meaningful between two publishes that never lost the live key. To remove one bad tier, edit and publish; do not delete the object. +- **`CDN_S3_*` and `AP_S3_*` are two different buckets on two different providers, and mixing them puts public objects in customer data.** `cdn.activepieces.com` is a **DigitalOcean Spaces** bucket, `activepieces-cdn` in `fra1`, fronted by Cloudflare — a 200 from it carries `x-rgw-object-type` and `x-do-cdn-uuid`, which is how you tell. That is where `ai/model-catalog.json`, `ai/pricing.json` and `sdk/embed/*.js` live, and the credentials are the `CDN_S3_*` GitHub secrets. `AP_S3_BUCKET` is something else entirely: the **Cloudflare R2** file-storage bucket (`ap-files-prod` in production) holding run logs, step checkpoints, webhook payloads and flow bundles. The names are similar enough that the pricing console was once configured with the R2 file-storage key by mistake, which would have written a `public-read` `ai/pricing.json` into the customer-data bucket while `cdn.activepieces.com` stayed 404. When wiring anything that publishes to the CDN, confirm the endpoint is the Spaces region origin (`https://fra1.digitaloceanspaces.com`, never with the bucket in the path — the SDK appends `Bucket` itself and the doubled path returns `NoSuchKey` on every call). +- **The weekly sync workflow can fail and nobody is told.** `sync-model-catalog.yml` has no alert on failure, and the only signal that it broke is a 404 on the object, which every reader swallows. Its run on 2026-08-31 failed with `InvalidAccessKeyId` — the `CDN_S3_ACCESS_KEY_ID` secret in this repo does not match a live key — so `ai/model-catalog.json` has never actually landed. Check `gh run list --workflow=sync-model-catalog.yml` before believing the CDN is the problem. **That same `CDN_S3_*` credential also publishes the AI tier file `ai/pricing.json` from `ap-analytics`** (decision [000039](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md)), so one dead key silently breaks model metadata *and* the published tiers at once: the console hides model costs (`model-catalog-service.ts` logs 'model prices will be hidden' and returns an empty list) while Activepieces keeps serving the tiers bundled in the release. Two features, one credential, no alarm on either. - **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists. +- **Since the AI steps moved to the worker, the server sees every step's model id, and `ai-execute-controller` is the one place to act on it.** The piece POSTs `/v1/ai/execute` with `provider` and `modelId`, the controller builds the `EXECUTE_AI` job from that body, and the worker runs whatever model id the job carries. So anything that needs to rewrite or validate the model a flow step is about to use belongs there, not in the piece — one server-side edit covers all five AI steps, and an old pinned piece bundle keeps working because it only forwards the string it stored. This is what makes storing a tier id in a step cheap: the bundle never has to understand it. Note the agent step does **not** come through here — it resolves separately over `getAgentConfig`, in `agent-config-rpc`. Two conditions sit under that. **An old bundle only forwards the string from `0.11.0` up** — `AI_PIECE_COST_BILLING_VERSION`, the first version whose `ai-step.ts` POSTs `v1/ai/execute`. A step pinned below it runs the pre-worker code that calls the provider from the sandbox, so a tier id there would reach OpenRouter as the literal string. That case is already closed: `migrate-v26-ai-piece-cost-billing` bumps every saved `piece-ai` step below `0.11.0` up to it, which is why a later migration can rewrite what a step stores without touching `pieceVersion` for the runtime. **And resolve on the API, never in the worker**: `ExecuteAiRequest.modelId` is a bare `z.string()` so a tier id passes validation, `getEffectiveProviderAndModel` and `aiUtils.buildWebSearchToolsOrThrow` both read `data.modelId` as a real model, and the `resolveAiProvider` RPC does not carry the model at all, so using it as the seam would mean widening the worker contract. +- **Managed billing can only price an OpenRouter call, and a call it cannot price is simply not billed.** `openRouterCostOf` (`server/utils/activepieces-ai-cost.ts`) reads `providerMetadata['openrouter'].usage.cost`, with one fallback that GETs `openrouter.ai/api/v1/generation?id=` and takes `total_cost`. If neither answers, `createBillingInfo` returns `undefined` and the turn is never reported — it runs, the customer keeps the output, and nothing is charged. Nothing logs "unbilled". This is what actually welds the managed provider to OpenRouter, more than the client does: `create-language-model.ts` shares one branch for `OPENROUTER` and `ACTIVEPIECES`, every tier `modelId` is an OpenRouter id, and `openrouter-api.ts` mints the per-platform key from `OPENROUTER_PROVISION_KEY`. So putting managed credits on any other gateway needs a second cost extractor and a second generation lookup **first**, or every call through it bills zero. For reference, no Vercel AI Gateway code exists here at all — the only gateway in the repo is Cloudflare's (`CLOUDFLARE_GATEWAY`, the `ai-gateway-provider` package), and `@ai-sdk/gateway` appears only as a transitive dep of `ai` that nothing imports. A BYOK user can already reach Vercel's gateway today through `AIProviderName.CUSTOM`, which takes a base URL plus hand-typed model ids. +- **A tier id that no longer exists resolves two different ways, and only the run source decides which.** `getAgentConfig` branches on `namesItsOwnModel` (true for `AgentRunSource.FLOW_STEP` and `AGENT`): those go through `resolveNamedModelId`, which **throws** `The model "X" is not available on Activepieces AI credits` for an id it cannot find, while every other source goes through `resolveModelIdForProvider` → `resolveTier`, which **silently returns the default tier**. So deleting a published tier moves existing chats onto the default with no error and no notice, and that is genuinely safe only while flow steps and agents still store concrete model ids. The moment a step stores `premium` it takes the throwing branch and every run of that flow fails, because the candidate list is model ids and never contains a tier id. Whoever lands the tier-id switch has to decide what a removed tier means before the console keeps telling admins that removing one is safe. +- **The console's Publish writes the live key directly; there is no draft, no staging and no filter.** `ai-pricing-service.ts` in `ap-analytics` writes `LIVE_PRICING_KEY` on Publish, and its "add tier" row seeds `id: tier-N`, `label: New tier`, so a half-finished experiment is one click from every server's cached tier list, with only the CDN `max-age=300` plus the 15-minute in-process TTL in between. Test against a local file with `AP_MODEL_TIERS_URL` (add the host to `AP_SSRF_ALLOW_LIST` for loopback), never by publishing. +- **The AI piece's managed dropdown only ever offers tier models, so every stored managed model id came from a tier pick.** `props.ts` (`packages/pieces/community/ai/src/lib/common/props.ts`) builds its options from `ACTIVEPIECES_CHAT_TIERS`, the constant compiled into the release, in two places: `managedModelLabel` supplies the label, and the same lookup is the `.filter` that decides which models are offered at all. There is no way to pin a model deliberately through it. Two consequences. A re-pointed tier is invisible to the AI piece in both directions: a saved step keeps its concrete model id, and a new step cannot pick the new model because it matches no bundled tier. And any migration from stored model ids to tier ids has a trivially complete mapping, since each stored id is one of the three bundled tier model ids of the release it was saved on. Until the dropdown emits tier ids, the AI piece only ever follows the release. +- **`AIProviderModel` is declared twice, and a field added to one half is stripped on the wire.** There is one in `core/piece-types` (what pieces import, since they cannot import `@activepieces/shared`) and one in `core/shared` (what the server and the response schema use). They are not re-exports of each other — today the shared copy carries `metadata` and the piece-types copy does not. Fastify serialises the response against the *shared* schema and drops every key it does not declare, so adding a field to the piece-types copy alone type-checks in the piece, type-checks in the server, and arrives as `undefined`. A published tier label was nearly shipped that way. Any new field on a provider model needs both files. +- **No image model belongs to a chat tier, so a tier-based filter empties the image picker.** The managed provider is OpenRouter underneath and `openrouter-provider.listModels` tags a model `IMAGE` when its `architecture.output_modalities` says so — `ACTIVEPIECES` is deliberately absent from `NO_IMAGE_GENERATION_PROVIDERS`, and `google/gemini-2.5-flash-image` and friends are real managed models. But `ACTIVEPIECES_CHAT_TIERS` is three *chat* tiers, so no image model can ever match one. Any filter of the form "a managed model must belong to a tier" therefore drops every image model silently, leaving `aiProps({ modelType: 'image' })` with an empty dropdown and no error. Gate that filter on `modelType === 'text'`. +- **`premium` pairs `modelId: anthropic/claude-opus-4.8` with `nativeModelId: claude-opus-4-7` on purpose.** The managed tier runs Opus 4.8 through OpenRouter; a customer's own Anthropic key runs Opus 4.7. The reason is cost on the customer's key: Heavy on a BYOK key must not silently move the customer onto the most expensive model without them choosing it. The console's `defaults.ts` carries the same pair. Do not "fix" either copy. - **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both. - **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run. @@ -84,7 +113,7 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **The OpenAI-compatible provider can speak the Responses API, and `@ai-sdk/openai-compatible` is not how you get there.** That SDK exposes only `chatModel` (`/chat/completions`) and `completionModel` — no responses model in any version. The route is `@ai-sdk/openai`, which accepts a custom `baseURL` + `headers` and exposes `.responses()`; the Cloudflare gateway's openai branch already did this before the `apiStyle: 'chat' | 'responses'` config field existed. Header precedence is the subtlety: the SDK builds `{ Authorization: Bearer , ...options.headers }`, so a caller header wins on `Authorization` (the Bedrock case, where the key *is* the bearer token) but a custom `apiKeyHeader` such as `x-api-key` leaves the SDK's default `Authorization` riding along. `withUserAgentSuffix` drops `undefined` values, but `OpenAIProviderSettings.headers` is typed `Record`, so the way to strip it is the header-stripping `fetch` the Cloudflare branch uses — no cast needed, and worth reaching for straight away rather than writing the duplicate header up as an accepted limitation. Strip only when `apiKeyHeader` is not itself Authorization, compared **case-insensitively**: HTTP header names are, and an admin can type `authorization`. Confirmed shape, since it is easy to assert on the wrong key: the SDK lowercases everything, so a mis-set provider really does emit `{authorization: 'Bearer ', 'x-api-key': ''}` — the credential twice. - **AWS's OpenAI-compatible surface is two endpoints, and "Bedrock Mantle" is a real AWS product name, not a customer's nickname.** `bedrock-runtime.{region}.amazonaws.com/openai/v1` (AWS-recommended) and `bedrock-mantle.{region}.api.aws/v1` both serve Chat Completions *and* Responses, both authenticate with a Bedrock API key as `Authorization: Bearer`, and both take an unmodified OpenAI SDK. Mantle alone adds server-side tools and web search, `background=true` async inference, and Projects/Workspaces; bedrock-runtime alone has Guardrails, cross-Region inference and intelligent prompt routing. So a ticket claiming "Mantle only supports responses" is half right — it serves chat/completions too, and the real loss is the Responses-only capabilities. - **Vertex is a different door onto the same models, not a different vendor.** The `GOOGLE` provider is the Gemini Developer API (`generativelanguage.googleapis.com`, static API key); `VERTEX` is Vertex AI (`{region}-aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{region}/publishers/google`, service-account OAuth2 with ~1h tokens) — the same Gemini models, but billed to the customer's GCP account and inheriting IAM, VPC-SC, CMEK, data residency and audit logs, plus Model Garden's Claude/Llama/Mistral. That rotating token is why the CUSTOM provider can never reach Vertex: `buildOpenAICompatibleHeaders` injects one static header, which is why the workaround used to be a LiteLLM proxy. `@ai-sdk/google-vertex` does the JWT-to-token exchange itself, so the native provider needs no proxy. Vertex models are entered by hand (`MANUAL_MODEL_PROVIDERS`) on purpose: availability is project-, region- and Model-Garden-specific, and a `publishers/google` listing would miss exactly the third-party models that motivate choosing Vertex. **But "same models" only holds for Gemini.** Model Garden also serves Anthropic, Meta, Mistral and xAI, and those do not share Gemini's API surface — `@ai-sdk/google-vertex` ships separate `/anthropic`, `/maas` and `/xai` entry points for exactly that reason, so one `createVertex(...)` call does not cover Vertex. Because models are typed in by hand, a Claude id reaches the factory as an ordinary string and, unrouted, gets built with the Gemini client and fails at the endpoint. `create-language-model.ts` routes ids containing `claude` to `createVertexAnthropic`; `vertexClientFor` picks the client from the id shape, so all three are routed. Because the shapes are unambiguous the check is a lookup, not a tuned heuristic — and note the publisher prefix has to be tested *before* the `claude` one, or a MaaS path containing "claude" routes to the Anthropic client. Routing is cheap because the id shapes are distinct, not because a heuristic was tuned: `gemini-2.5-pro` is bare, Anthropic carries an `@date` (`claude-3-5-sonnet@20241022`), and MaaS is publisher-prefixed and suffixed (`meta/llama-4-scout-17b-16e-instruct-maas`). The publisher prefix is a stronger discriminator than the `includes('claude')` match currently shipped, and it looks like an argument for listing models rather than taking them by hand — but it is not, see below: the listing is scoped to a single publisher, so Model Garden ids stay hand-typed and the string matching stays with them. **Listing was considered and rejected**, and the reason is worth keeping so it does not get reopened, and **the response shape does not need a GCP account to learn** — every Google API publishes an unauthenticated discovery document: `curl -s 'https://aiplatform.googleapis.com/$discovery/rest?version=v1beta1'` returns ~5MB of JSON whose `schemas` are the authoritative request/response types. `publishers.models.list` returns `{ publisherModels: PublisherModel[], nextPageToken }`, and `PublisherModel` is `{ name: 'publishers/google/models/', versionId, versionState (STABLE|UNSTABLE), launchStage (GA|PUBLIC_PREVIEW|PRIVATE_PREVIEW|EXPERIMENTAL), openSourceCategory, supportedActions, predictSchemata, frameworks, parent }`. **There is no `displayName` on it** — the one in `google-vertexai`'s piece comes from `@google/genai`'s `Model` type, which is a different shape, so copying that code gives every row a blank label with nothing failing. `displayName` exists only on the nested `parent`, which is the base model a tuned model derives from. The decisive fact is in that same doc: `publishers.models.list` takes `parent` matching `^publishers/[^/]+$` — **one publisher per call**. Listing everything therefore means hardcoding the set of publishers to enumerate, which is the same shape of hardcoding it was meant to remove, and Model Garden models would still need hand entry, leaving two mechanisms where there is now one. `VERTEX` stays in `MANUAL_MODEL_PROVIDERS` alongside `CUSTOM` and `CLOUDFLARE_GATEWAY`, which fits: Vertex model availability really is per-project. Reach for the discovery doc before a live probe on any `*.googleapis.com` integration; a curl against the real endpoint needs **billing attached to the project even for a read** (`403 BILLING_DISABLED`), so it is the slower path and it is not free to set up. The `x-goog-user-project` header is not optional when probing with a personal login: without it aiplatform answers `403 SERVICE_DISABLED` naming `consumer: projects/32555940559`, which is Google's shared gcloud CLI project rather than yours, and the message talks about quota projects rather than the missing header. This is a user-credential quirk only — a service account carries its own `project_id`, so the provider itself never hits it. **The multi-vendor story is text only.** Vertex's image models are Google's own Imagen (`imagen-3.0-*`, `imagen-4.0-*`), not Model Garden's third parties, and `createVertex(...)` also exposes `imageModel`, `video`, `speech`, `transcription` and `textEmbeddingModel` — none of which we wire today. Imagen is wired (`createVertex(...).imageModel()`, with a `VERTEX` case in the piece's image switch, so `VERTEX` is no longer in `NO_IMAGE_GENERATION_PROVIDERS`); video, speech, transcription and embeddings are not. Embeddings still have no `DEFAULT_EMBEDDING_MODELS` entry, so that path paths fail with an explanatory message instead of reaching a switch that has no case for it. -- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-helpers.ts`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` and `agent-draft-ai` also call it but hold only a provider name and set a stored default on a draft, so they are correctly left thin. Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's. +- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-model-resolution.ts`, spread into `agentHelpers`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` also calls it but holds only a provider name and sets a stored default on a draft, so it is correctly left thin (`agent-draft-ai` did too, until #15676 deleted it). Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's. - **A failed credential validation tells the admin nothing, for every provider except Cloudflare Gateway.** `aiProviderService.validateProviderCredentials` gates the upstream message behind `includeHttpErrorInMessage`, which is `provider === CLOUDFLARE_GATEWAY` and nothing else, so everyone else gets a bare `Failed to validate credentials for `. The cause is not lost — it is logged one line earlier (`log.error({ error }, '[aiProviderService#validateProviderCredentials] ...')`) and passed as the `httpErrorResponse` error param — but **web never renders `httpErrorResponse`**, so the only way to diagnose a rejected key is the server log. Grep the log for `validateProviderCredentials` before assuming the provider integration is broken — that text is the whole diagnosis, and it is often not about credentials at all. Confirmed case: a brand-new xAI team with no credits purchased answers `GET /v1/models` with `403 permission-denied — Your newly created team doesn't have any credits or licenses yet`, naming the console page that fixes it, and we render that as "Failed to validate credentials for xAI" — sending the admin off to regenerate a key that was never wrong. Vendors also phrase real key failures inconsistently (xAI uses `400 Incorrect API key provided`, not a 401). The corollary: **a provider that saves without error is not a working provider.** A no-credits 403 and a bad key are indistinguishable in the UI, so only an actual generation proves a key end to end. This is an admin-only surface (`platformAdminOnly`), so there is little reason to keep hiding it. - **A provider's logo is an asset someone has to upload, not something the code ships.** `AiProviderInfo.logoUrl` in `packages/web/src/features/agents/ai-providers.ts` is a plain string rendered into an ``, and every provider points at `https://cdn.activepieces.com/pieces/.png` — nothing is bundled. Adding a provider therefore carries a cross-team dependency with no compile-time or test signal: a slug with no asset behind it renders a broken-image icon in the platform admin list, and only a live request tells you. Check the URL with `curl -o /dev/null -w '%{http_code}'` before assuming it works — a vendor that already ships as a *piece* usually has its logo there already (`deepseek.png`, `grok-xai.png` did), so start the upload request only for the genuinely missing ones. A Vite asset import also satisfies `logoUrl` (see `GoogleIcon` in `platform/security/sso/index.tsx`) and removes the runtime CDN dependency for air-gapped installs, but it diverges from every other provider — treat it as a fallback, not the default. - **The manual-models panel stores a model id and nothing else, so every model an admin adds is typed `TEXT`.** `MANUAL_MODEL_PROVIDERS` (Vertex, Custom, Cloudflare Gateway) persist a `ProviderModelConfig` of `{ modelId, modelName, modelType }`, but since #14825 the UI keeps only `string[]`: `ManualModelList` is a chip list of ids, and `config-detail.tsx` rebuilds the objects on save with `?? { modelId, modelName: modelId, modelType: TEXT }`. The old `universal-pieces/model-form-popover.tsx` had all three fields; replacing it with chips dropped two of them, and no schema or test noticed because the reconstruction is type-correct. The visible damage is not just a mislabelled model: `cloudflareGatewayProvider.validateConnection` pings **only text models** with a `/chat/completions` request, so one image model added through the UI is validated as chat, fails, and takes the whole key down with `These models have issues: ...` — the admin cannot save a key that has an image model on it at all. Anything reading `modelType` downstream (the agent text-model filter, `ap-list-ai-models`, the builder's image pickers) is wrong the same way. Fixed by holding `ProviderModelConfig[]` in the draft rather than `string[]` (no migration; stored rows always had the right shape), so the standing rule is that **a manual-models UI must carry `modelType` all the way to the payload** — a panel that collapses models to ids reintroduces exactly this, and nothing downstream will complain until an admin adds an image model. Keys saved while the bug was live still hold image models typed `TEXT`; the type badge on each chip flips one in place, which is why it is editable rather than display-only. diff --git a/brain/knowledge/ai-intelligence/index.md b/brain/knowledge/ai-intelligence/index.md index 88d81a4e6e82..1fdb7365f62c 100644 --- a/brain/knowledge/ai-intelligence/index.md +++ b/brain/knowledge/ai-intelligence/index.md @@ -13,9 +13,18 @@ A configured LLM backend (OpenAI, Anthropic, Google, Azure, OpenRouter, Cloudfla The metered currency for AI usage — one credit is `AP_AI_CREDIT_USD_VALUE` of model spend, $0.0005 by default — backed by per-key OpenRouter limits. A quota, not a wallet. A managed call is billed on the dollar cost the provider reports; a call on a customer's own key is billed one flat credit. See [decision 000037](../decisions/000037-ai-is-billed-on-what-the-call-cost-observed-at-the-worker.md). - *Avoid:* "tokens" for the billing unit; tokens are the model's unit, credits are ours. *Avoid:* "credit weight" — the per-model weight table is gone. +### 🎚️ Model Tier +A named slot, Fast, Expert or Heavy, that a customer picks instead of a model, so we can move it to a newer model without a deploy. It carries `id`, `label`, `modelId`, an optional `nativeModelId` for a customer on their own key, and a `thinkingBudget`. Tiers are published to the CDN by the console (decision [000041](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md)) in two lists and fall back to `ACTIVEPIECES_CHAT_TIERS` in the release. Tier ids are `fast` / `smart` / `premium`; only `fast` matches its label (`smart` is Expert, `premium` is Heavy), so match on `id`, never the label. A tier id never contains `/`; a managed model id always does. +- **Flow tiers** — the `tiers` list; what FLOW_STEP and AGENT resolve against, the surfaces that name their own model. **Chat tiers** — the `chatTiers` list; what CHAT and AGENT_BUILDER resolve against. The chat list is optional and falls back to the flow list. In code the pair is `ModelTierSurface = 'chat' | 'flow'`. +- *Avoid:* "chat tier" for a model tier in general; say "model tier", and "chat tiers" only for the chat list. The other real axis is text vs image (`ACTIVEPIECES_IMAGE_TIERS`). + ### 🤖 Agent A flow step that runs an autonomous LLM loop rather than a single call. Its **AgentTool**s are Piece, Flow, MCP, or Knowledge Base handles. +### 🚪 Run source +The door an agent turn came through. `AgentRunSource` is CHAT (the assistant chat, an agent turn with no agent row), AGENT (chatting with a saved agent), AGENT_BUILDER (the chat that builds an agent) and FLOW_STEP (the `run_agent` action). All four create an `agent_conversation` row and run the same `EXECUTE_AGENT_RUN` worker job; the source decides the tool set, the system prompt and which tier list the model resolves against. See the four-doors table on the AI Agents page. +- *Avoid:* "surface" for the door itself; surface is the two-way `chat | flow` split the tier lists use, and two doors map onto each. + ### ⚡ Direct AI step A flow step that makes one model call and returns: Ask AI, Summarize Text, Classify Text, Extract Structured Data, Generate Image. The counterpart to an **Agent**, which loops — and the distinction decides how each reaches a model, see [decision 000035](../decisions/000035-direct-ai-steps-reuse-the-agents-suspend-and-resume-path.md). - *Avoid:* "simple AI action" and "AI action" — both name the piece rather than the behaviour, and would wrongly include the Agent step, which also lives in the AI piece. diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index d28cb69017b5..b275858e7ffa 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -49,6 +49,13 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, its next refresh, which backfills a NULL key), and `NULL` is not a third state — it means "signed in before the column existed" and reads as `unknown` everywhere, including the `?clientKeys=unknown` filter. - **Revocation only reaches a live access token through the `grantId` claim, and a token without one is trusted.** `revoked = true` is read by the refresh flow, never by `POST /mcp`, so the Revocation List is what actually cuts a connected client off — see [000033](../decisions/000033-a-revoked-mcp-grant-is-blocked-by-a-redis-revocation-list.md). It is keyed on `grantId` (the `mcp_oauth_token` row id) because `clientId` is a DCR registration shared across users, so revoking by it would kill strangers' tokens. Two populations carry no claim and therefore skip the check entirely: tokens minted before the claim shipped (they age out in 15 minutes, and `refreshAccessToken` stamps the claim) and `issueInternalAccessToken` tokens for EE chat, which have no grant row at all. Anything that mints an MCP access token without a grant row inherits that exemption silently. Platform teardown is a third gap by design — it `DELETE`s the rows instead of revoking them, so no keys are written. +- **The platform `disabledTools` list is the outer bound on every MCP server; the project list narrows it.** A project can switch off more than the platform, never switch back on what the platform switched off. + A platform client picks its project after it connects, so `executeInSelectedProject` checks the selected project's live list at call time: a switched-off tool stays listed, refuses when called, and is not charged. + `LOCKED_TOOL_NAMES` are exempt from both lists; `isToolEnabled` in `mcp-server-builder.ts` is the one place that rule lives. +- **The MCP page has no Pieces tab: pieces are the second segment of Tools.** Built-in and Pieces share one project picker, because both answer "what can a connected client call in this project". The segment rides the URL as `?segment=`, and the old `/mcp-server/pieces` address is its own route, `LegacyPiecesRedirect`, that keeps `?project=`. Flows-as-tools and the project `/mcp` URL live only in the embedded MCP settings dialog. +- **The Tools tab edits the selected project's `disabledTools`, not the session project's.** It posts to `POST /v1/projects/:projectId/mcp-server` with the picker's project, so the role that matters is the role there: `McpTools` reads `WRITE_MCP` through `useAuthorization(projectId)` and disables every checkbox without it. `platformDisabledTools` comes back beside the project list, and `McpTools` shows those tools as off, locked and badged, here and in the embedded dialog. `useMcpNav` drops a `?project=` that is not an `ApId`, because `..` survives `encodeURIComponent` and the browser would resolve the path to the platform server's own row. +- **The four `PLATFORM_LEVEL_TOOL_NAMES` never reach `executeInSelectedProject`, so they carry their own permission gate.** `ap_research_pieces`, `ap_search_actions`, `ap_search_triggers` and `ap_get_piece_props` run against `templateMcp` (projectId = platformId) and return early in `registerPlatformTools`, which means the per-project `PermissionChecker` never touches them. Membership is earned by guarding on `mcpUtils.isProjectScoped(mcp)` and passing `undefined` instead of the fake project id — `ap_list_ai_models` was listed here without that guard, so `rowAllowsScope` tested the platform id against an AI provider row's `projectIds`, hiding `selected`-scoped providers from everyone and offering `except`-scoped ones to the very projects they exclude. It routes through the selected project now. Without a gate of their own a member whose `READ_MCP` was revoked everywhere kept calling them on an already-issued token, and kept being charged for it. `withMcpReach` now wraps them with `mcpAccess.hasMcpReach`, the same any-project rule `POST /v1/mcp-oauth/approve` applies at authorization time, and it wraps the `charged` call so a refusal costs nothing. `ap_set_project_context` is wrapped the same way; enforcing the rule from inside its `execute` alone left the denial on the far side of `charged`, so a user with reach nowhere paid a credit per refusal. +- **`READ_MCP` gates external MCP clients, not the in-app chat.** The chat reaches its tools through `POST /mcp/platform` like any client, and the controller serves it through the project branch once the conversation resolves a project, so `resolveMcpPermissionChecker` would deny every chat tool to a custom role with MCP set to None. `isInAppChatIdentity` in `mcp-oauth.controller.ts` picks the chat out as a *platform-scoped* token carrying `INTERNAL_CHAT_CLIENT_ID`, and `buildMcpServer` then uses `resolvePermissionChecker` (per-tool checks only, as the chat's own `agent-tools.ts` path does). Both halves are needed: `POST /v1/projects/:id/mcp-server/token` mints the same client id, but always project-scoped, and `resolveIdentity` rejects a scope mismatch, so those embed-SDK tokens keep the gate. Only the project branch relaxes; `registerPlatformTools`, `withMcpReach` and `executeInSelectedProject` stay gated, because the platform branch is the fallback for a conversation with no project or one the user lost access to. - **Claude Code and Codex re-run Dynamic Client Registration on *every* sign-in**, registering the exact ephemeral loopback port they are about to bind (`http://localhost:/callback`, `http://127.0.0.1:/callback/`). So the exact-string `validateRedirectUri` works and @@ -101,6 +108,12 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, - **The Pieces tab's search is server-side, and it only works because `pieceDisplayName` is a Fuse key.** `/v1/pieces?searchQuery=` replaces each piece's `actions` with the matched subset (`searchForSuggestion`), which sounds fatal for a page that shows a per-piece action count and a destructive badge — but `searchForSuggestion` searches `['pieceDisplayName', 'displayName', 'description']`, so querying a *piece* name matches every action inside it and the row still lists the lot. Two more things make it safe: `toPieceMetadataModelSummary` computes `summary.actions` from the pre-search `audiencePieces`, so the total count is never narrowed by a query, and the tab force-expands every row while searching, so the count it renders is visibly the list beneath it. Keep the popular-first sort for the unsearched view only — applying it to search results throws away Fuse's relevance ranking. Rows are still grouped and counted client-side in `piecesUtils.toReachablePieces`, which is a pure function with its own unit test. +- **The MCP page is gated on MCP *reach*, not on the session project, and `RoutePermissionGuard` cannot express that.** The server rule is "`READ_MCP` in at least one project" — what `POST /v1/mcp-oauth/approve` and `withMcpReach` both enforce — while `checkAccess` resolves the session project alone, so a member holding `READ_MCP` in project B while sitting in project A could use the platform server but got a 404 on the page handing out its address, and no MCP entry in the rail. `GET /v1/mcp-server/reach` answers the right question in one call; nothing else could, because `GET /v1/projects` carries no role or permission field and `listProjectIdsWithPermission` had no HTTP surface. It is the one `publicPlatform` route on a controller whose other three are `platformAdminOnly`, and it returns project ids only. A privileged caller gets `projectIds: null` rather than an enumeration of the platform — `getAllForUser` skips its filter for them, so listing every id would load every project row on a large platform, and both readers, `McpReachGuard` and the `ProjectPicker` filter, already read `null` as "every project". An embedded user is refused in the handler, through `userIdentityHelper.isUserEmbedded`, the same way `POST /v1/mcp-oauth/approve` refuses them the platform-wide branch — their `project_member` rows are never removed, so the id list would span every tenant they ever signed into. The refusal cannot be a route-level `securityAccess.nonEmbedUsersOnly`: despite the name, that helper also demands `WRITE_INVITATION` on some project, so it would refuse the very members this endpoint exists for. Treat it as auxiliary: it fails open, because an outage on it must not lock people out of a page the server would have opened. + +- **MCP never lists or authorizes another user's personal project, even for an admin or operator.** `getAllForUser` returns every project of the platform to a privileged user and `getRole` gives an operator EDITOR on each, but `mcp-access.ts` drops a `PERSONAL` project unless the caller owns it, in both `listAccessibleProjects` and `hasMcpAccessToProject`. It matches `getUserProjects` in `ee/agent/agent-helpers.ts`, so the in-app chat and an external client see the same projects. `GET /v1/mcp-server/reach` still answers `null` for a privileged caller; its readers filter `/v1/projects`, which already hides those projects. + +- **`mcp-access.ts` is CE code that imports two EE modules, and that is deliberate for now.** It reaches `ee/authentication/project-role/rbac-middleware` and `ee/projects/project-members/project-member.service`, against `.claude/rules/edition-safety.md`. It is safe at run time only because every EE call sits behind `editionRequiresRbac()` and `repoFactory` is lazy, so on Community nothing is touched. `mcp-permissions.ts` opened this breach before `mcp-access.ts` widened it. Moving it behind a `hooksFactory` is the correct fix and is its own change — do not half-do it while editing something else here. + ### Key files Entry point: `mcpServerModule`, the Fastify plugin in `mcp/mcp-module.ts` registered from `packages/server/api/src/app/app.ts`. @@ -110,7 +123,7 @@ Entry point: `mcpServerModule`, the Fastify plugin in `mcp/mcp-module.ts` regist - `packages/server/api/src/app/mcp/oauth/` — OAuth 2.0 PKCE flow: metadata, authorize, token, revoke - `packages/core/shared/src/lib/automation/mcp/` — McpServer schema, McpToolDefinition, MCP OAuth types - `packages/web/src/app/components/project-settings/mcp-server/` — settings panel: credentials, flows-as-tools, tool toggles -- `packages/web/src/app/routes/mcp-server/` — the Connect, Pieces and Grants tabs +- `packages/web/src/app/routes/mcp-server/` — the Connect, Tools, Connections and Activity tabs; Tools holds the Built-in / Pieces segments - `packages/web/src/app/routes/mcp-authorize/` — standalone OAuth consent page and its permission item - `packages/web/src/app/routes/embed/` — the `embedded-mcp-*` dialogs for managed-auth consent and settings - `packages/ee/embed-sdk/src/index.ts` — embed SDK public methods `authorizeMcp()`, `mcpSettings()`, `generateMcpToken()` diff --git a/brain/knowledge/connections-auth/app-connections.md b/brain/knowledge/connections-auth/app-connections.md index 8c3af0820df6..ac051b335e6c 100644 --- a/brain/knowledge/connections-auth/app-connections.md +++ b/brain/knowledge/connections-auth/app-connections.md @@ -43,3 +43,7 @@ Entry point: `appConnectionService`, exported from the app-connection service an - `packages/web/src/app/routes/platform/setup/connections/` — platform-wide global connections page Paths verified 2026-07-17. + +## Gotchas + +- **`needRefresh` looks the piece up by `connection.pieceVersion`, so a CUSTOM_AUTH connection whose stored version is no longer served fails as `ConnectionNotFound`, not as a version error.** Any 404 from `GET /v1/worker/app-connections/:externalId` is mapped by the engine to "connection not found", which points debugging at credentials. On a dev instance this happens the moment a dev piece's version is bumped; in production it would need a published version to disappear from the registry. The lookup result is cached per piece version in `pieceRefreshSupportCache`. diff --git a/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md b/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md new file mode 100644 index 000000000000..21210624a7e5 --- /dev/null +++ b/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md @@ -0,0 +1,85 @@ +--- +icon: 🪙 +status: accepted +--- + +# AI model tiers are published to the CDN from a second console + +## Decision + +The chat model tiers — `id`, `label`, `modelId`, `nativeModelId`, `thinkingBudget` — and +`defaultTierId` move out of the code and into `cdn.activepieces.com/ai/pricing.json`. A **new repo**, `activepieces/ap-analytics`, is where +marketing edits them and presses Publish. Activepieces fetches that file (15-minute TTL), keeps the last +good copy, and falls back to the constants shipped with the release. The existing billing console +is not touched. + +There is **no database table for the tiers**. The CDN file is the source of truth. Every publish +also writes `ai/pricing/history/-.json`, which gives history and one-click undo +with no table. + +## Context + +Every tier was a constant in the monorepo, so moving Expert to a newer model meant a code change, +a release and a self-host upgrade. Marketing needed to do it themselves. Two problems fell out of that: +where the UI lives, and how the values travel. + +The billing console already holds license keys, Autumn customers and money. Its own rule is +"Architecture A+: one codebase, separate Supabase project per sensitive module" — but that +separates only the **database**. It does not stop a config feature from breaking a deploy that +also serves billing, and it does not stop anyone (or any agent) editing files next to that code. + +## Why + +A second repo separates the code and the deploy, not just the data. Config work can move fast +without touching the repo that holds the master billing key. + +Publishing to the CDN rather than serving a live console endpoint keeps **billing correctness off +the console's uptime**. The fetch/cache/backoff/stale-fallback code already existed and was proven +(`model-catalog.ts`), so the reader is a copy of a working thing rather than new machinery. +Rejected: AP calling a console API on every read, which would make a config tool an availability +dependency of charging customers. + +No table because the CDN object already gives durable storage, and archive copies give history for +one extra upload call. Rejected: a versioned Postgres table, which is a migration, a schema and a +second copy of the truth for no gain. + +The cost is propagation delay (about 20 minutes, from `max-age=300` plus a 15-minute in-process TTL) and +that publishing needs CDN write credentials in the new repo. The current key can write anything in +the bucket; a key scoped to `ai/pricing*` is the follow-up. + +## Consequences + +- **The pricing schema now lives in two repos** and they must match. `packages/shared/src/lib/ai-pricing/` + in ap-analytics, `packages/server/utils/src/model-tier-catalog.ts` here. Change one without the + other and Activepieces rejects the file and silently keeps serving the old tiers. The reader + ignores keys it does not declare, which is what kept the console publishable while it still sent + the credit fields; ap-analytics dropped those on 2026-09-20 and added `nativeModelId`, so the two + schemas name the same five tier fields again. +- A tier change is no longer visible in `git log` of this repo. The audit trail is the CDN history + prefix. +- The reader **fails closed**: bad schema, no tiers, or a missing default tier all fall back to the + last good file, then to the release's own `ACTIVEPIECES_CHAT_TIERS`. CE and self-hosted keep + working with zero setup. +- Publishing is blocked whenever the live file cannot be read, so a CDN outage can never overwrite + tiers nobody can see. +- **This started out as a price file and is not one any more.** It originally carried + `MANAGED_MODEL_WEIGHTS`, a per-tier `creditWeight` and `unpricedModelCreditWeight`. Main deleted + every weight table in [#15494](https://github.com/activepieces/activepieces/pull/15494) and now + bills managed AI on the dollar cost the provider reports per call, so there is no fixed price left + to publish. Only the tier table survived. The object is still called `ai/pricing.json` because it + is already live and the console writes there; the name is now wrong, and renaming it needs both + repos plus the live object to move together. The console page, its nav entry and its docs were + renamed to **AI Tiers** instead, because an editor that still showed a credits box let an admin + change a number, press Publish and believe they had changed billing. +- **The two guards on publishing are not equally strict, which is easy to misread.** Reading the live + `pricing.json` before a write fails *closed* (above). The separate check that every priced model id + exists in `ai/model-catalog.json` fails *open*: when the catalog cannot be loaded, + `ai-pricing-service.ts` logs 'The model catalog is unavailable, so model ids were not checked before + publishing' and publishes anyway. So a typo in a tier's `modelId` can reach the CDN, and every chat + turn and agent run on that tier then asks the provider for a model that does not exist. +- Config features from now on go in `ap-analytics`, not the billing console. +- **The repo is named `ap-analytics` on GitHub, but calls itself "config console" inside** — README, + `CLAUDE.md` and the package names all say config-console. It was created as `config-console` and + renamed on 2026-09-02. A clone made before the rename pushes into a 404; fix it with + `git remote set-url origin git@github.com:activepieces/ap-analytics.git`. +- **Internal tools run on DigitalOcean, but not all the same way.** `activepieces/discover` uses a **droplet** driven by Actions over SSH (`.github/workflows/deploy.yml` — build, rsync, `pm2 reload`), with nginx and certbot in front. `ap-analytics` uses **App Platform** instead: DO builds the Dockerfile, redeploys on push by itself, and issues TLS, so there is no server to log into and the repo carries no deploy workflow at all. Neither repo says which it is, so check the DO dashboard before assuming. Runbook: `DEPLOYMENT.md` in ap-analytics. diff --git a/brain/knowledge/engineering/ci-pipeline.md b/brain/knowledge/engineering/ci-pipeline.md index acdbd443c5aa..815370ca5d21 100644 --- a/brain/knowledge/engineering/ci-pipeline.md +++ b/brain/knowledge/engineering/ci-pipeline.md @@ -4,7 +4,7 @@ icon: ⏱️ # CI Pipeline -The PR checks that decide whether a change builds and passes tests. Lives in `.github/workflows/ci-v2.yml` (`ci.yml` runs beside it until the side-by-side comparison ends). The gates that shape *how a PR is reviewed* are on *CI PR Review Hygiene*. +The PR checks that decide whether a change builds and passes tests. Lives in `.github/workflows/ci.yml`; it replaced the original `ci.yml` on 2026-09-27 after running beside it as `ci-v2.yml`. The gates that shape *how a PR is reviewed* are on *CI PR Review Hygiene*. **changes job** — the first job. Runs `turbo ls --affected` to list the packages the PR touches plus everything that depends on them, and sets `all=true` when a file outside any package changed (workflows, root config, `tools/`). Every other job keys off those two outputs. @@ -23,14 +23,16 @@ The PR checks that decide whether a change builds and passes tests. Lives in `.g - api's `test` script runs ce+ee+cloud serially. Any `turbo run test` over many packages must exclude api (`--filter='!api'`; `tools/scripts/test-filters.ts` does the same). - The api e2e tests (`execute-flow-e2e`, `test-step-e2e`, `piece-options-e2e`) import the worker's source, and `worker.ts` imports `@activepieces/sandbox`, which resolves to `packages/server/sandbox/dist`. A job that builds only api fails those three files with "Failed to resolve entry for package @activepieces/sandbox". The api jobs build `worker` as well and also run when worker is affected. - A cache saved during a pull request run is visible only to that PR and to nothing else. Caches every PR should hit (bun downloads, the compiled Redis binary) must be created on `main`; `warm-ci-cache.yml` does that on every push to main. -- Two turbo invocations running at the same time in one job race on `cache: false` builds: the old `ci.yml` failed `api#build` with "@activepieces/shared has no exported member" for a member that exists, because a second invocation was rewriting `shared/dist` while api's tsc read it. One invocation per job. +- Two turbo invocations running at the same time in one job race on `cache: false` builds: the retired pipeline failed `api#build` with "@activepieces/shared has no exported member" for a member that exists, because a second invocation was rewriting `shared/dist` while api's tsc read it. One invocation per job. - `--affected` and `--filter` intersect in turbo; there is no union flag. To run "the affected packages plus this fixed set" in one invocation, pass the affected names (the `changes` job outputs them as JSON) as explicit `--filter=` arguments alongside the fixed ones. Two invocations rebuild the shared dependency chain twice because `build` is uncached. - Pieces are the one place the pipeline overrides the dependency graph, by decision (2026-09-24). A piece runs when its own files changed; every piece runs when `pieces/framework` or `pieces/common` changed. A change to `core-piece-types`, `core-utils`, `core-formula` or `core-execution` does not pull the 700+ pieces in, although they depend on it, because that cost about 20 minutes per such PR (5 of the last 60). The accepted blind spot: a core type change that breaks piece compilation is caught when pieces are published (`release-pieces.yml`), not in the PR. The `changes` job computes this with two turbo listings, `ls --filter='[base...HEAD]'` (changed directly) and `ls --affected` (plus dependents). +- The engine's tests execute five pieces (`AP_DEV_PIECES` in `packages/server/engine/vitest.config.ts`: http, data-mapper, approval, webhook, delay), so those five are engine devDependencies. That edge is what makes `--affected` pull engine in when one of them changes; without it a pieces-only PR ran the piece's checks and skipped the engine tests that exercise it (caught on the retirement PR, 2026-09-27). Three lists name the same set and must move together: that `AP_DEV_PIECES`, `@activepieces/engine#test` `dependsOn` in `turbo.json`, and engine's devDependencies. - `TURBO_SCM_BASE` must be `origin/` in CI. turbo's default base is `main`, which a CI checkout does not have, so without it every run degrades to "everything affected". +- Required status checks are set by a repo admin (branch ruleset on `main`) and match on job display name. A job its `if:` skips reports `skipped`, which GitHub counts as passing, so every job here can be required: `changes`, `lint`, `build-test`, `api (ce)`, `api (cloud)`, `api (ee, unit, migrations)`, `tool-search (postgres)`. A workflow-level skip (`on.paths`) leaves a required check pending forever, so keep the filtering inside the `changes` job. On 2026-09-27, when the old `ci.yml` was retired, nothing was required yet: `isRequired` was false on every check and the only ruleset enforced codeowner review. ## Key files -- `.github/workflows/ci-v2.yml` — the PR pipeline +- `.github/workflows/ci.yml` — the PR pipeline - `.github/actions/setup` — shared install and cache steps - `tools/scripts/test-filters.ts` — every package with a `test` script, minus api - `tools/scripts/check-migration-rollback.ts` — new migrations must declare `breaking` and `down()` diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 121f51ef3613..b3c72cb7ff69 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -63,13 +63,14 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **The same integration test can exist once per edition, so changing a shared service means grepping the assertion, not trusting the file you already edited.** `passwordless-authn.test.ts` lives under `test/integration/ce/authentication/` on main, and a branch may carry its own copy elsewhere — a behaviour change to `requestCode` or `signUp` has to update every copy. This bites hardest after rebuilding a branch onto a different base, which resurrects files the old base had moved: the edit list from the first attempt is then silently incomplete, and because api unit tests do not gate CI (below), the edition copy is the only thing that catches it. Grep the *assertion* (`DOMAIN_NOT_ALLOWED`, the fixture domain) across `test/` rather than the filename. - **When a refusal and a success deliberately share a status code, a status-only assertion passes for the wrong reason.** The invited-member test kept asserting `204` and kept passing after the guard it covered stopped running at all. Any silent-failure design has to be pinned on side effects — rows created, mail sent, spies called — because the response is by construction indistinguishable. - **In a vitest unit test, import the module under test statically — `vi.mock` is hoisted above imports.** The existing `worker-group.service.test.ts` reaches for `await import(...)` to load its subject after the mocks, which is unnecessary and, if you copy it to the *top level* of a file rather than inside a function, fails `tsc -p tsconfig.spec.json` with `TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to …`. Vitest itself runs it happily and lint says nothing, so the only thing that catches it is a typecheck nobody gates on. A plain `import { thing } from '…'` alongside the `vi.mock` calls works and typechecks. -- **A unit test added under `packages/server/api/test/unit/` never runs in CI.** `ci.yml` runs exactly two test commands: `turbo run test` filtered to engine/shared/sandbox/ai-providers/pieces-framework/web, and `turbo run test-ce test-ee test-cloud check-migrations --filter=api`. The api package *has* a `test-unit` script (`vitest run test/unit`), but no workflow invokes it and the root `test-unit` filter list does not include api — so the 10+ files already sitting in `test/unit/**` are dead weight, and a new one passes review while protecting nothing. `packages/core/execution` is in the same position. Until the wiring changes, put api coverage that must actually gate merges in `test/integration/ce|ee|cloud`, and if you do add a unit test, say in the PR that you ran it locally and paste the result. +- **`packages/server/api/test/unit/` runs in CI since 2026-09: the `api (ee, unit, migrations)` job runs `test-unit` whenever api or worker is affected.** Before the affected-package pipeline no workflow invoked api's `test-unit` script, so the files there rotted unseen. The root `npm run test-unit` still skips api; run `npx turbo run test-unit --filter=api` locally instead. - **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself. - **The api package's `lint` script is `eslint 'src/**/*.ts'`, so nothing under `packages/server/api/test/` is ever linted.** `npx turbo run lint --filter=api` reports 0 errors on a tree whose test files carry real `import-x/order` **errors** — `test/integration/ce/mcp/mcp-activity-recording.test.ts` has had three since it was written. Point ESLint at the test paths yourself when you touch them (`npx eslint test/integration/ce/`), because neither CI nor `npm run lint-dev` will. Related: a lint run over a whole `src/app/` tree OOMs at node's default heap in this repo — `NODE_OPTIONS=--max-old-space-size=8192` gets it through. - **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access — author association, not the diff, is what the gate reads. - **`license/cla` keys off the commit author email, so re-opening someone else's branch under your own name does not clear it.** CLA-assistant walks every commit in the PR rather than the PR author, and an author email that matches no GitHub account can never be matched to a signature — the 47 commits carried over onto [#15092](https://github.com/activepieces/activepieces/pull/15092) were authored as `ashrafsam@mac.lan`, a local hostname, so the check sat at `not_signed` on a PR opened by a member. It is not in the `main` ruleset's required-checks list, but it is red on the page and a reviewer reads that as unmergeable. Either the original author signs through the PR link, or the commits get re-authored to an email tied to their GitHub account before you open it. - **A branch that predates the `brain/` → `brain/knowledge/` move cannot edit a brain page in place — GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push. -- **`breaking-change-check` couples the docs entry to the label in BOTH directions, so back-documenting an already-shipped change drags the label onto a docs-only PR.** R3 in `tools/scripts/breaking-change-check.ts` fails a PR that adds a `####` entry to `docs/install/reference/breaking-changes.mdx` without `⛓️‍💥 breaking-change`, exactly as it fails the label without an entry — and the template answer has to agree too, so "yes" must be ticked on a PR that changes no code. It reads the *added lines of that one file* from `git diff origin/...HEAD`, and `hasBreakingEntry` wants a `####` heading **plus** a non-heading body line, so a heading alone, a `---`, or a version bump does not count. Two consequences: the label then collides with `skip-changelog` in release-drafter (pick one deliberately — the feature's own PR usually already carried the changelog entry), and an entry appended to a *released* section still trips it, since the check never looks at which heading the lines landed under. +- **Merging `main` into your branch makes Greptile review `main`'s shipped code as yours, and the score drops for files you never touched.** It appears to review the commits in the PR's range rather than the diff against the base, so a merge commit drags the commits it brought in along with it — [#15550](https://github.com/activepieces/activepieces/pull/15550) went 4/5 → 3/5 on a finding in `agent-conversation-service.ts` and `agent-run-controller.ts`, both arriving from #15548 in a merge done minutes earlier, neither in the PR's own diff. The check is one command: `git diff --name-only origin/main HEAD` — if a "Files Needing Attention" entry is not in that list, it is not this PR's. (Three-dot `origin/main...HEAD` works too; after merging `main` the merge base *is* `main`, so the two agree.) Do not stop at "not mine", though: that finding was real, a lookup by id with no `projectId` filter, and it is live on `main` where nobody is reviewing it any more — report it against the owning PR rather than letting the misattribution bury it. Rebasing instead of merging avoids the whole effect, but this repo merges, so expect it after every catch-up. +- **`breaking-change-check` couples the docs entry to the label in BOTH directions, so back-documenting an already-shipped change drags the label onto a docs-only PR.** R3 in `tools/scripts/breaking-change-check.ts` fails a PR that adds a `####` entry to `docs/install/reference/breaking-changes.mdx` without `⛓️‍💥 breaking-change`, exactly as it fails the label without an entry — and the template answer has to agree too, so "yes" must be ticked on a PR that changes no code. It reads the *added lines of that one file* from `git diff origin/...HEAD`, and `hasBreakingEntry` wants a `####` heading **plus** a non-heading body line, so a heading alone, a `---`, or a version bump does not count. Two consequences: the label then collides with `skip-changelog` in release-drafter (pick one deliberately — the feature's own PR usually already carried the changelog entry), and an entry appended to a *released* section still trips it, since the check never looks at which heading the lines landed under. **When the check fails, removing the label is sometimes the right fix, not writing an entry.** A long-lived branch can stop being breaking underneath you — merging `main` can delete the very thing that was breaking, and then the label and the template answer are both stale while the code is fine. Re-read what the branch still does against the template's own list before reaching for `breaking-changes.mdx`. Handy either way: the job re-runs on a **label change**, so flipping the label clears it with no empty commit and no push. - **A release cut empties `## Unreleased` into a version section, so a long-lived branch's entry conflicts in a way that is wrong to resolve by hand.** The entries do not move to the bottom of the file — the whole Unreleased block is relabelled as the new version and a fresh Unreleased is opened above it. A branch that added its entry weeks ago then conflicts against a section that has been relocated wholesale, and git presents it as "your entire block versus one stray line", which invites resolving the hunk in place. Doing that leaves the entry sitting inside an already-released section, where it is both wrong for readers and invisible to `breaking-change-check` (see the bullet above: the check never looks at which heading the added lines landed under, so CI stays green). Take main's copy of the file whole and re-insert the entry at the end of the *current* Unreleased section instead, then check that the entries you unioned on an earlier merge each appear exactly once — the previous resolution's placement is usually now a duplicate. Seen on GIT-1764 / #14899, which conflicted twice in one day for this reason. - **Nothing rolls `## Unreleased` over at release time, and the docs site is unversioned — so a breaking-changes entry has to name its own version.** No workflow or script writes to `docs/install/reference/breaking-changes.mdx` (`breaking-change-check.ts` only reads it), and `git log -S"## 0.88"` on the file comes back empty: the heading has not moved since 0.87.0, so entries for work that shipped months ago still sit under "Unreleased" (PM2 removal in 0.88.2, cache pre-warm gate and workspace naming in 0.89.0, …). `docs/docs.json` has no versioning either, so there is one live page for every self-hoster whatever version they run, published on merge rather than on release — the version heading is the *only* thing telling a reader whether a change is already in their build. So before adding an entry, run `git tag --contains ` on the change it describes and file it under the release that actually shipped it; only genuinely unshipped work belongs under "Unreleased". What points self-hosters at the page in the first place is `release-drafter.yml`, which appends a "review the Breaking Changes page" line to every release body and groups `⛓️‍💥 breaking-change` PRs under their own heading — which also means a docs-only PR back-documenting an old change shows up in the *next* release's breaking-change list. - **Greptile enforces the file-order rule on *private* constants too, which CLAUDE.md only states for exported ones.** CLAUDE.md says "Exported types and constants must be placed at the end of the file" and gives the order as imports → exports → helpers → types; Greptile reads that as covering module-private constants as well, and flags a `const` sitting above the file's exported symbol (P2 on [#15226](https://github.com/activepieces/activepieces/pull/15226), for two constants only read inside the service they sat above). It has that as a stored custom-context memory, so it will keep raising it. Put private constants in the helpers section below the export — hoisting is a non-issue when they are only read at call time. diff --git a/brain/knowledge/engineering/engineering-handbook-playbooks.md b/brain/knowledge/engineering/engineering-handbook-playbooks.md index 649ee54a6301..e6add8a47e75 100644 --- a/brain/knowledge/engineering/engineering-handbook-playbooks.md +++ b/brain/knowledge/engineering/engineering-handbook-playbooks.md @@ -21,6 +21,7 @@ Run EE, building for self-hosting, setup BetterStack, releases, canary deploymen Postmortems (Redis/queue overload, infra upgrade — March 2026) and product interface-design notes. ## Gotchas +- **Put a scratch worktree *inside* the repo root, or it cannot lint or typecheck at all.** Node resolves `node_modules` by walking up the directory tree, so a worktree at `/tmp/...` finds nothing and eslint dies on `couldn't find the plugin "@typescript-eslint/eslint-plugin"` before it reads a single rule — no install, no symlink, no checks. The same worktree created at `.claude/worktrees/` walks up to the repo root's `node_modules` and runs lint, `tsc` and vitest with zero setup. This is cheaper than both workarounds in the next bullet and has none of the cross-branch skew, because the resolved packages belong to the same checkout. It matters most during a long conflict resolution: files you edit while resolving are easy to treat as "already reviewed" and never check again, and in an unusable worktree you *cannot* check them. A merge resolved in `/tmp` shipped a `sort-imports` error and a `TS2783` to CI this way — both in files touched only during conflict resolution, both caught by the first `turbo run lint` / `turbo run typecheck` that could actually run. After a merge, run the task CI runs (`turbo run lint --filter=`), not a list of the files you remember editing. - **Running EE locally (outside the dev container) to preview a chat/agents-gated view hits four macOS-specific walls the [Run EE](https://www.activepieces.com/docs/handbook/engineering/playbooks/run-ee) playbook doesn't cover.** (1) The default dev DB, PGlite (`.env.dev`'s `AP_DB_TYPE=PGLITE`), hard-refuses `AP_EDITION=ee`/`cloud` with `Error: Edition ee not supported in pglite mode in dev environment` — point at a real local Postgres instead (`AP_DB_TYPE=POSTGRES` + a throwaway DB via `createdb`). (2) In a fresh git worktree, symlinking `node_modules` from another worktree/checkout works for lint/typecheck but not for actually running the server: workspace packages resolve through the symlink to whatever commit *that* checkout is on, so a genuine cross-branch source/type skew appears (e.g. `AIProviderName.VERTEX does not exist`) purely from the mismatch — do a real `bun install` scoped to the worktree instead. (3) `bun install` aborts the *entire* install (not just its own package) if `isolated-vm`'s native build fails, which it does on Darwin 25 (`node-gyp` / `undici` `webidl.util.markAsUncloneable is not a function`) — even though `isolated-vm` is only needed by the engine/worker sandbox, not by `api`+`web`. Fix: `bun install --ignore-scripts`, then `cd` into `node_modules/.bun/sqlite3@*/node_modules/sqlite3` and run `npx prebuild-install -r napi` directly to fetch its prebuilt native binding (something in the queue/api dependency tree requires it to *load* even when the configured DB isn't sqlite). (4) The repo requires Node 22.15+/24 (`bootstrap.ts` uses `zlib.zstdDecompress`, added in Node 22.15); a system default of Node 20 (common via `nvm`) crashes API boot with `ERR_INVALID_ARG_TYPE` in `file-compressor.ts` — put a `nvm`-installed v24 first on `PATH` before starting `serve`. Once booted: `AP_EDITION=ee` alone (no license key) is enough to preview chat/agents, since the unlicensed `AUTUMN_FREE_PLAN` already sets `chatEnabled`/`agentsEnabled: true` — but that plan's `aiProvidersEnabled: false` hides the "Connect" button on every provider card, so there is no UI path to satisfy chat's "Set up an AI provider" gate; insert a throwaway row into `ai_provider` directly via SQL (`enabledForChat: true`, `config: '{}'`, any `auth` JSON — it's only decrypted when a message is actually sent) to get past it for pure layout/UI verification. - **`DISCORD_ON_CALL_WEBHOOK` is the single repo secret behind every on-call Discord notification** — cloud production deploys (`continuous-delivery-cloud.yml`), self-hosted releases (`release-self-hosted.yml`), and release-pieces failure alerts all post through it. Rotating it repoints all of them at once; there is no per-workflow webhook. Self-hosted release notifications skip `-rc` tags unless `publish_rc_release` is set, mirroring the release-drafter condition so the message never links a release page that doesn't exist. - **Docs media lives on the CDN, never in the repo — and videos and images sit on two different paths.** A video embeds as `