diff --git a/.github/actions/cache-mount/action.yml b/.github/actions/cache-mount/action.yml new file mode 100644 index 00000000000..df0de0b106c --- /dev/null +++ b/.github/actions/cache-mount/action.yml @@ -0,0 +1,36 @@ +name: Cache Mount +description: Mount a build cache directory using Blacksmith sticky disks, or the GitHub Actions cache when running on GitHub-hosted runners. + +inputs: + provider: + description: Empty or 'blacksmith' selects sticky disks; anything else selects actions/cache. + required: false + default: '' + key: + description: Cache key, shared by both backends. + required: true + path: + description: Path to mount. + required: true + +# Predicates must stay the exact complement of the caller's runs-on — stickydisk +# only runs on Blacksmith. +runs: + using: composite + steps: + - name: Mount sticky disk + if: inputs.provider == '' || inputs.provider == 'blacksmith' + uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + with: + key: ${{ inputs.key }} + path: ${{ inputs.path }} + + # run_id suffix + prefix restore-key: actions/cache skips save on an exact hit, + # which would freeze the cache at the first run's contents. + - name: Restore and save cache + if: inputs.provider != '' && inputs.provider != 'blacksmith' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + key: ${{ inputs.key }}-${{ github.run_id }} + restore-keys: ${{ inputs.key }}- + path: ${{ inputs.path }} diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml new file mode 100644 index 00000000000..bb241713490 --- /dev/null +++ b/.github/actions/docker-build/action.yml @@ -0,0 +1,59 @@ +name: Docker Build and Push +description: Set up a buildx builder and build/push an image, using Blacksmith's builder or the upstream Docker actions on GitHub-hosted runners. + +inputs: + provider: + description: Empty or 'blacksmith' selects Blacksmith's builder; anything else selects the docker/* actions. + required: false + default: '' + context: + description: Build context. + required: false + default: . + file: + description: Path to the Dockerfile. + required: true + platforms: + description: Target platforms, e.g. linux/amd64. + required: true + tags: + description: Comma-separated list of tags to push. + required: true + +# Registry logins must precede this action. provenance/sbom stay off: attestation +# manifests break `imagetools create` retagging in promote-images. +runs: + using: composite + steps: + - name: Set up Blacksmith builder + if: inputs.provider == '' || inputs.provider == 'blacksmith' + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 + + - name: Build and push (Blacksmith) + if: inputs.provider == '' || inputs.provider == 'blacksmith' + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: ${{ inputs.context }} + file: ${{ inputs.file }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ inputs.tags }} + provenance: false + sbom: false + + - name: Set up Docker Buildx + if: inputs.provider != '' && inputs.provider != 'blacksmith' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + # No cache-to: type=gha — it shares the 10 GB repo quota with the cache mounts. + - name: Build and push (GitHub) + if: inputs.provider != '' && inputs.provider != 'blacksmith' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: ${{ inputs.context }} + file: ${{ inputs.file }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ inputs.tags }} + provenance: false + sbom: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca2901c7871..0aa7a40ed9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,20 @@ name: CI +# Runner provider toggle, read from the CI_PROVIDER repo variable: +# +# gh variable set CI_PROVIDER --body github # fall back to GitHub-hosted +# gh variable delete CI_PROVIDER # back to Blacksmith (default) +# +# A repo variable, not a committed value: during a Blacksmith outage there is no +# working CI to merge a switchover through. Only unset/'blacksmith' selects +# Blacksmith; anything unrecognized selects GitHub so a typo can't queue jobs +# against the provider you're escaping. Every runs-on and both composite actions +# share this predicate and must change together. +# +# GitHub mode is break-glass, not a peer — cold layers, slower runs. The app image +# is the one job on a paid larger runner: next build needs ~32 GB and OOM-kills +# (exit 137) on the free 16 GB runners at any heap ceiling. + on: push: branches: [main, staging, dev] @@ -28,7 +43,7 @@ jobs: # Detect if this is a version release commit (e.g., "v0.5.24: ...") detect-version: name: Detect Version - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') outputs: @@ -81,7 +96,7 @@ jobs: name: Build Dev ECR needs: [detect-version, migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -90,14 +105,20 @@ jobs: fail-fast: false matrix: include: + # Only the app image needs the paid 8-core/32 GB runner: next build + # exhausts the free 16 GB one (exit 137). The others build in <5 min. - dockerfile: ./docker/app.Dockerfile ecr_repo_secret: ECR_APP + gh_runner: linux-x64-8-core - dockerfile: ./docker/db.Dockerfile ecr_repo_secret: ECR_MIGRATIONS + gh_runner: ubuntu-latest - dockerfile: ./docker/realtime.Dockerfile ecr_repo_secret: ECR_REALTIME + gh_runner: ubuntu-latest - dockerfile: ./docker/pii.Dockerfile ecr_repo_secret: ECR_PII + gh_runner: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -118,9 +139,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 - - name: Resolve ECR repo name id: ecr-repo run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT @@ -128,15 +146,12 @@ jobs: ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }} - name: Build and push - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + uses: ./.github/actions/docker-build with: - context: . + provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/amd64 - push: true tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev - provenance: false - sbom: false # Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch. # Gated after migrate-dev for the same reason as build-dev — the new task @@ -145,7 +160,7 @@ jobs: name: Deploy Trigger.dev (Dev) needs: [migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout code @@ -192,7 +207,7 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -205,15 +220,19 @@ jobs: - dockerfile: ./docker/app.Dockerfile ghcr_image: ghcr.io/simstudioai/simstudio ecr_repo_secret: ECR_APP + gh_runner: linux-x64-8-core - dockerfile: ./docker/db.Dockerfile ghcr_image: ghcr.io/simstudioai/migrations ecr_repo_secret: ECR_MIGRATIONS + gh_runner: ubuntu-latest - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME + gh_runner: ubuntu-latest - dockerfile: ./docker/pii.Dockerfile ghcr_image: ghcr.io/simstudioai/pii ecr_repo_secret: ECR_PII + gh_runner: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -242,9 +261,6 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 - - name: Resolve ECR repo name id: ecr-repo run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT @@ -270,15 +286,12 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push images - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + uses: ./.github/actions/docker-build with: - context: . + provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/amd64 - push: true tags: ${{ steps.meta.outputs.tags }} - provenance: false - sbom: false # Promote the sha-tagged ECR images to the deploy tags once tests and # migrations pass. Pushing the ECR latest/staging tag is what triggers @@ -292,7 +305,7 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 permissions: contents: read @@ -362,7 +375,7 @@ jobs: # never moves a documented tag. build-ghcr-arm64: name: Build ARM64 (GHCR Only) - runs-on: blacksmith-8vcpu-ubuntu-2404-arm + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404-arm' || matrix.gh_runner }} timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: @@ -374,12 +387,16 @@ jobs: include: - dockerfile: ./docker/app.Dockerfile image: ghcr.io/simstudioai/simstudio + gh_runner: linux-arm64-8-core - dockerfile: ./docker/db.Dockerfile image: ghcr.io/simstudioai/migrations + gh_runner: ubuntu-24.04-arm - dockerfile: ./docker/realtime.Dockerfile image: ghcr.io/simstudioai/realtime + gh_runner: ubuntu-24.04-arm - dockerfile: ./docker/pii.Dockerfile image: ghcr.io/simstudioai/pii + gh_runner: ubuntu-24.04-arm steps: - name: Checkout code @@ -392,26 +409,20 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 - - name: Build and push ARM64 to GHCR - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + uses: ./.github/actions/docker-build with: - context: . + provider: ${{ vars.CI_PROVIDER }} file: ${{ matrix.dockerfile }} platforms: linux/arm64 - push: true tags: ${{ matrix.image }}:${{ github.sha }}-arm64 - provenance: false - sbom: false # Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags) # and the multi-arch manifests from the immutable sha tags — only on main, # after the deploy gate (promote-images) and the ARM64 build both pass. create-ghcr-manifests: name: Create GHCR Manifests - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 needs: [promote-images, build-ghcr-arm64, detect-version] if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -477,7 +488,7 @@ jobs: # Check if docs changed check-docs-changes: name: Check Docs Changes - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && github.ref == 'refs/heads/main' outputs: @@ -506,7 +517,7 @@ jobs: # Create GitHub Release (only for version commits on main, after all builds complete) create-release: name: Create GitHub Release - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 needs: [create-ghcr-manifests, detect-version] if: needs.detect-version.outputs.is_release == 'true' diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index 76cf58c584e..b1eb9a28f49 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -10,7 +10,7 @@ permissions: jobs: process-docs-embeddings: name: Process Documentation Embeddings - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 593bf854a4e..ab177ed0c5d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -24,7 +24,7 @@ permissions: jobs: migrate: name: Apply Database Migrations - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 45 steps: diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index da24a3959be..54f8b0b7038 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -11,7 +11,7 @@ permissions: jobs: publish-npm: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout repository diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml index 1890c10b9f5..98e598b11d8 100644 --- a/.github/workflows/publish-python-sdk.yml +++ b/.github/workflows/publish-python-sdk.yml @@ -11,7 +11,7 @@ permissions: jobs: publish-pypi: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout repository diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index f64f3867a74..e6bb8891b5f 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -11,7 +11,7 @@ permissions: jobs: publish-npm: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout repository diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index f20cae5f7d6..ed348a41446 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -10,7 +10,7 @@ permissions: jobs: test-build: name: Lint and Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 steps: @@ -27,25 +27,28 @@ jobs: with: node-version: 22 - # Sticky-disk keys are scoped by event name, and fork PRs get their own - # namespace on top: untrusted fork runs must never share a disk with - # push runs (whose disks feed production image builds) or with trusted + # Cache keys are scoped by event name, and fork PRs get their own + # namespace on top: untrusted fork runs must never share a cache with + # push runs (whose caches feed production image builds) or with trusted # internal-PR runs. - - name: Mount Bun cache (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount Bun cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ~/.bun/install/cache - - name: Mount node_modules (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount node_modules + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./node_modules - - name: Mount Turbo cache (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount Turbo cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-turbo-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo @@ -150,6 +153,11 @@ jobs: - name: Type-check realtime server run: bunx turbo run type-check --filter=@sim/realtime + # cloud-review-tools.test.ts runs the real helper on the runner, which shells + # out to rg. Blacksmith's image ships it, GitHub's doesn't. + - name: Install ripgrep + run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) + - name: Run tests with coverage env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' @@ -186,9 +194,11 @@ jobs: # harmless), but the Turbo cache gets its own key: with a shared key, only # the last committer's new entries survive each run, so the test and build # Turbo entries would evict each other nondeterministically. + # Same next build as the app image, so it needs the same larger runner in + # GitHub mode: it peaks ~21.9 GB and SIGKILLs on the free 16 GB ones. build: name: Build App - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }} timeout-minutes: 15 steps: @@ -205,21 +215,24 @@ jobs: with: node-version: 22 - - name: Mount Bun cache (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount Bun cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ~/.bun/install/cache - - name: Mount node_modules (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount node_modules + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./node_modules - - name: Mount Turbo cache (Sticky Disk) - uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + - name: Mount Turbo cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} key: ${{ github.repository }}-turbo-cache-build-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo diff --git a/apps/docs/content/docs/en/integrations/slack.mdx b/apps/docs/content/docs/en/integrations/slack.mdx index c90a179c1b9..b02ea9e6cfb 100644 --- a/apps/docs/content/docs/en/integrations/slack.mdx +++ b/apps/docs/content/docs/en/integrations/slack.mdx @@ -815,7 +815,7 @@ Set or clear the assistant thread status indicator (the loading shimmer) on a Sl | `botToken` | string | No | Bot token for Custom Bot | | `channel` | string | Yes | Channel ID containing the assistant thread \(e.g., C1234567890 or D1234567890\) | | `threadTs` | string | Yes | Thread timestamp \(thread_ts\) of the assistant thread \(e.g., 1405894322.002768\) | -| `status` | string | Yes | Status text to display, e.g. 'Working on it…'. Pass an empty string to clear. | +| `status` | string | No | Status text to display, e.g. 'Working on it…'. Omit or pass an empty string to clear the status. | | `loadingMessages` | json | No | Optional list of messages to rotate through as an animated loading indicator \(max 10\). | #### Output diff --git a/apps/pii/server.py b/apps/pii/server.py index fdd7c1b2604..05b23e9b0b1 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -176,23 +176,50 @@ def build_analyzer() -> AnalyzerEngine: batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer) anonymizer = AnonymizerEngine() -# Every entity the spaCy NER recognizers can produce. A request touching any of -# these must run spaCy; a request naming only non-NER (regex/checksum) entities can -# skip it. Derived from the live registry so it stays authoritative if Presidio's -# default entity set changes (e.g. ORGANIZATION), unioned with a known floor so an -# unexpectedly empty derivation can never let an NER request skip the NLP pass. +# Every entity the spaCy NER recognizers can actually produce. A request touching +# any of these must run spaCy; a request naming only non-NER (regex/checksum) +# entities can skip it. The registry's SpacyRecognizers CLAIM every entity in +# Presidio's default NER-model mapping — including PHONE_NUMBER/AGE/ID/EMAIL, +# which exist for transformer models and which no spaCy model can emit — so the +# claimed set is intersected with the entities the loaded models' NER labels map +# onto. Without that filter, selecting PHONE_NUMBER (present in nearly every +# redaction rule) silently forces the full spaCy pass and the regex-only fast +# path never fires. The floor guarantees the core NER entities always take the +# full pass even if label introspection ever derives empty. _SPACY_NER_FLOOR = frozenset({"PERSON", "LOCATION", "NRP", "DATE_TIME", "ORGANIZATION"}) -NER_ENTITIES = _SPACY_NER_FLOOR | frozenset( - entity - for recognizer in analyzer.registry.recognizers - if isinstance(recognizer, SpacyRecognizer) - for entity in recognizer.supported_entities + + +def _producible_ner_entities() -> frozenset: + """Presidio entities some loaded spaCy model's NER labels map onto.""" + configuration = getattr(analyzer.nlp_engine, "ner_model_configuration", None) + mapping = configuration.model_to_presidio_entity_mapping if configuration else {} + producible = set() + for nlp in getattr(analyzer.nlp_engine, "nlp", {}).values(): + if "ner" not in nlp.pipe_names: + continue + for label in nlp.get_pipe("ner").labels: + entity = mapping.get(label) + if entity: + producible.add(entity) + return frozenset(producible) + + +NER_ENTITIES = _SPACY_NER_FLOOR | ( + frozenset( + entity + for recognizer in analyzer.registry.recognizers + if isinstance(recognizer, SpacyRecognizer) + for entity in recognizer.supported_entities + ) + & _producible_ner_entities() ) # One blank NlpArtifacts per language, built once at startup. Passing these to # analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely: -# the pattern recognizers still match on the raw text, SpacyRecognizer is excluded -# by the entity filter, and score_threshold is unset so detection is identical. +# the pattern recognizers still match on the raw text, SpacyRecognizer finds +# nothing in blank artifacts (and can only be consulted at all for claimed-but- +# unproducible entities like PHONE_NUMBER), and score_threshold is unset so +# detection is identical. # Only context-based score boosting (which needs real tokens) is unavailable — an # accepted trade for skipping NER on the hot block-output path. Read-only, so it # is safe to share across requests and workers. diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index 6f54de56b6c..0e75c76e230 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -483,7 +483,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => { providerSlug, contextWindow: model.contextWindow ?? null, releaseDate: model.releaseDate ?? null, - deprecated: model.deprecated ?? false, + deprecated: !!model.sunset, pricing: model.pricing, capabilities: mergedCapabilities, capabilityTags, diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 399caeb3b66..bc765862b4c 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -20,8 +20,9 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckChatAccess } = vi.hoisted(() => ({ +const { mockCheckChatAccess, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), + mockValidateChatDeployAuth: vi.fn(), })) const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -50,10 +51,20 @@ vi.mock('@/lib/core/utils/urls', () => ({ vi.mock('@/app/api/chat/utils', () => ({ checkChatAccess: mockCheckChatAccess, })) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed based on your permission group settings') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' +import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat Edit API Route', () => { beforeEach(() => { @@ -213,6 +224,60 @@ describe('Chat Edit API Route', () => { expect(data.message).toBe('Chat deployment updated successfully') }) + it('returns 403 when the updated auth type changes to a blocked mode', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + authType: 'password', + workflowId: 'workflow-123', + }, + workspaceId: 'workspace-123', + }) + mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'public' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(403) + expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-123', 'public') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('does not re-check the auth mode when it is unchanged (grandfathered)', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id' }, + }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + authType: 'public', + workflowId: 'workflow-123', + }, + workspaceId: 'workspace-123', + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'public', title: 'Renamed' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockValidateChatDeployAuth).not.toHaveBeenCalled() + }) + it('rejects the update without admitting a new deploy while an attempt is in flight', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) mockCheckChatAccess.mockResolvedValue({ diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 0afb00b152a..08acae6daa0 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -23,6 +23,10 @@ import { createErrorResponse, createSuccessResponse, } from '@/app/api/workflows/utils' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' export const maxDuration = 120 @@ -119,6 +123,20 @@ export const PATCH = withRouteHandler( return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400) } + // Enforce the permission group's chat auth-mode allow-list only when the + // mode actually changes, so a grandfathered mode already saved on this chat + // can still be re-saved (e.g. a title-only edit) without a 403. + if (authType && authType !== existingChatRecord.authType && chatWorkspaceId) { + try { + await validateChatDeployAuth(session.user.id, chatWorkspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + return createErrorResponse(error.message, 403) + } + throw error + } + } + if (identifier && identifier !== existingChat[0].identifier) { const existingIdentifier = await db .select() diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 59d6a72b0e4..d85f62bd2b4 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -16,8 +16,9 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkflowAccessForChatCreation } = vi.hoisted(() => ({ +const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckWorkflowAccessForChatCreation: vi.fn(), + mockValidateChatDeployAuth: vi.fn(), })) const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -32,6 +33,16 @@ vi.mock('@/app/api/chat/utils', () => ({ checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, })) +vi.mock('@/ee/access-control/utils/permission-check', () => { + class ChatDeployAuthNotAllowedError extends Error { + constructor() { + super('This chat authentication mode is not allowed based on your permission group settings') + this.name = 'ChatDeployAuthNotAllowedError' + } + } + return { validateChatDeployAuth: mockValidateChatDeployAuth, ChatDeployAuthNotAllowedError } +}) + vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('@/lib/core/config/env', () => @@ -42,6 +53,7 @@ vi.mock('@/lib/core/config/env', () => ) import { GET, POST } from '@/app/api/chat/route' +import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat API Route', () => { beforeEach(() => { @@ -237,6 +249,40 @@ describe('Chat API Route', () => { ) }) + it('returns 403 when the chat auth type is blocked by the permission group', async () => { + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-id', email: 'user@example.com' }, + }) + + const validData = { + workflowId: 'workflow-123', + identifier: 'test-chat', + title: 'Test Chat', + authType: 'public', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + dbChainMockFns.limit.mockResolvedValueOnce([]) + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: true, + workflow: { userId: 'user-id', workspaceId: 'workspace-1', isDeployed: true }, + }) + mockValidateChatDeployAuth.mockRejectedValueOnce(new ChatDeployAuthNotAllowedError()) + + const req = new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + body: JSON.stringify(validData), + }) + const response = await POST(req) + + expect(response.status).toBe(403) + expect(mockValidateChatDeployAuth).toHaveBeenCalledWith('user-id', 'workspace-1', 'public') + expect(mockPerformChatDeploy).not.toHaveBeenCalled() + }) + it('passes chat customizations and outputConfigs through in the API request shape', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id', email: 'user@example.com' }, diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index ca1f8019fe7..03f4e5377af 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -11,6 +11,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performChatDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('ChatAPI') @@ -101,6 +105,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse('Workflow not found or access denied', 404) } + if (workflowRecord.workspaceId) { + try { + await validateChatDeployAuth(session.user.id, workflowRecord.workspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + return createErrorResponse(error.message, 403) + } + throw error + } + } + const result = await performChatDeploy({ workflowId, userId: session.user.id, diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index ccd3ba7334a..a0666d2c6eb 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -35,6 +35,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true }) } + // Mothership (sidebar) chats are soft-deleted through their own route so + // they land in Recently Deleted — this legacy hard-delete must not bypass + // that. + if (chat.type === 'mothership') { + return NextResponse.json( + { success: false, error: 'Use the mothership chat delete endpoint' }, + { status: 400 } + ) + } + const [deleted] = await db .delete(copilotChats) .where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id))) diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index c7fa4d58b3f..0047b7e6f11 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript' @@ -189,7 +189,13 @@ export async function GET(req: NextRequest) { updatedAt: copilotChats.updatedAt, }) .from(copilotChats) - .where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter)) + .where( + and( + eq(copilotChats.userId, authenticatedUserId), + isNull(copilotChats.deletedAt), + scopeFilter + ) + ) .orderBy(desc(copilotChats.updatedAt)) const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}` diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 823b9a94aba..c4e3c0f350b 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { addCopilotChatResourceContract, @@ -64,7 +64,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const [chat] = await db .select({ resources: copilotChats.resources }) .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt) + ) + ) .limit(1) if (!chat) { @@ -91,7 +97,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { await db .update(copilotChats) .set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() }) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt) + ) + ) logger.info('Added resource to chat', { chatId, resource }) @@ -124,7 +136,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { const [chat] = await db .select({ resources: copilotChats.resources }) .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt) + ) + ) .limit(1) if (!chat) { @@ -142,7 +160,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => { await db .update(copilotChats) .set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() }) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt) + ) + ) logger.info('Reordered resources for chat', { chatId, count: newOrder.length }) @@ -182,7 +206,13 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => { ), '[]'::jsonb)`, updatedAt: new Date(), }) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt) + ) + ) .returning({ resources: copilotChats.resources }) if (!updated) { diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index 2e7dfa134c8..b6ec71a7a9a 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -46,6 +46,7 @@ vi.mock('@/lib/copilot/chat/messages-store', () => ({ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), })) import { POST } from '@/app/api/copilot/chat/update-messages/route' diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 5f72001816b..f5c01ad73db 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -62,6 +62,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { .where( and( eq(copilotChats.userId, userId), + isNull(copilotChats.deletedAt), or( and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)), inAccessibleWorkspace diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 47505d2a94c..e58d9a037d4 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,8 +1,9 @@ import { asyncJobs, db } from '@sim/db' -import { tableJobs, workflowExecutionLogs } from '@sim/db/schema' +import { tableJobs, workflowDeploymentOperation, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, inArray, lt, sql } from 'drizzle-orm' +import { and, eq, exists, gt, inArray, lt, sql } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' @@ -17,6 +18,14 @@ const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000) const MAX_INT32 = 2_147_483_647 /** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */ const TABLE_JOB_RETENTION_HOURS = 24 +/** + * Terminal deployment operations older than this are pruned. Every reader of + * this table is latest-generation-only, and idempotency keys only need to + * survive a client retry window, so 30 days is generous. + */ +const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30 +const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000 +const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -221,6 +230,64 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + /** + * Prune terminal deployment operations past retention. HARD INVARIANT: + * the newest-generation row per workflow must always survive — the next + * deploy computes `generation = MAX(generation) + 1`, and the webhook + * registration store fences rows with lt/gt comparisons against stored + * generations, so generation reuse after a full wipe would permanently + * wedge that workflow's deployments. The `exists(newer)` predicate + * guarantees the max-generation row is never eligible; the status filter + * keeps in-flight rows (an outbox worker may still hold their fence). + */ + let deploymentOperationsPruned = 0 + try { + const deploymentOpRetention = new Date( + Date.now() - DEPLOYMENT_OPERATION_RETENTION_DAYS * 24 * 60 * 60 * 1000 + ) + const newerOperation = alias(workflowDeploymentOperation, 'newer_operation') + for (let batch = 0; batch < DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES; batch++) { + const prunable = db + .select({ id: workflowDeploymentOperation.id }) + .from(workflowDeploymentOperation) + .where( + and( + inArray(workflowDeploymentOperation.status, ['active', 'failed', 'superseded']), + lt(workflowDeploymentOperation.completedAt, deploymentOpRetention), + exists( + db + .select({ id: newerOperation.id }) + .from(newerOperation) + .where( + and( + eq(newerOperation.workflowId, workflowDeploymentOperation.workflowId), + gt(newerOperation.generation, workflowDeploymentOperation.generation) + ) + ) + ) + ) + ) + .limit(DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE) + + const deleted = await db + .delete(workflowDeploymentOperation) + .where(inArray(workflowDeploymentOperation.id, prunable)) + .returning({ id: workflowDeploymentOperation.id }) + + deploymentOperationsPruned += deleted.length + if (deleted.length < DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE) break + } + if (deploymentOperationsPruned > 0) { + logger.info( + `Pruned ${deploymentOperationsPruned} old deployment operations (retention: ${DEPLOYMENT_OPERATION_RETENTION_DAYS}d)` + ) + } + } catch (error) { + logger.error('Failed to prune old deployment operations:', { + error: toError(error).message, + }) + } + return NextResponse.json({ success: true, executions: { @@ -239,6 +306,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { tableJobs: { staleMarkedFailed: staleTableJobsMarkedFailed, }, + deploymentOperations: { + pruned: deploymentOperationsPruned, + retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS, + }, }) } catch (error) { logger.error('Error in stale execution cleanup job:', error) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index e6d5b73186c..7694f23b010 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -25,6 +25,46 @@ const logger = createLogger('McpOauthCallbackAPI') export const dynamic = 'force-dynamic' +class OauthCallbackStepTimeout extends Error { + constructor(step: string, ms: number) { + super(`MCP OAuth callback step "${step}" did not settle within ${ms}ms`) + this.name = 'OauthCallbackStepTimeout' + } +} + +/** + * Times and bounds one awaited step of the callback so a stalled operation + * surfaces as a labeled, logged error instead of hanging the request forever. + * The losing promise is not cancelled (a wedged DB/socket op can't be), so it + * settles in the background with its rejection swallowed; the point is that the + * request stops waiting on it and the logs name the exact step that stalled. + */ +async function timedStep(step: string, ms: number, fn: () => Promise): Promise { + const start = Date.now() + logger.info(`OAuth callback step start: ${step}`) + const work = Promise.resolve(fn()) + work.catch(() => {}) + let timer: ReturnType | undefined + try { + const value = await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new OauthCallbackStepTimeout(step, ms)), ms) + timer.unref?.() + }), + ]) + logger.info(`OAuth callback step done: ${step} (${Date.now() - start}ms)`) + return value + } catch (error) { + logger.error(`OAuth callback step failed: ${step} (${Date.now() - start}ms)`, { + error: toError(error).message, + }) + throw error + } finally { + clearTimeout(timer) + } +} + function escapeHtml(value: string): string { return value .replace(/&/g, '&') @@ -145,8 +185,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { serverId ) } + const serverUrl = server.url try { - assertSafeOauthServerUrl(server.url) + assertSafeOauthServerUrl(serverUrl) } catch { return respond( 'MCP OAuth requires https (or http://localhost for development).', @@ -157,16 +198,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } // Burn state before token exchange so a replayed callback cannot reuse it. - await clearState(row.id, 'callback:burn-before-exchange') + await timedStep('clearState(burn)', 10_000, () => + clearState(row.id, 'callback:burn-before-exchange') + ) - const preregistered = await loadPreregisteredClient(server.id) + const preregistered = await timedStep('loadPreregisteredClient', 15_000, () => + loadPreregisteredClient(server.id) + ) const provider = new SimMcpOauthProvider({ row, preregistered }) let result: Awaited> try { - result = await mcpAuthGuarded(provider, { - serverUrl: server.url, - authorizationCode: code, - }) + result = await timedStep('mcpAuthGuarded', 120_000, () => + mcpAuthGuarded(provider, { + serverUrl, + authorizationCode: code, + }) + ) } catch (e) { logger.error('Token exchange failed during MCP OAuth callback', e) return respond( @@ -176,7 +223,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { server.id ) } finally { - await clearVerifier(row.id) + await timedStep('clearVerifier', 10_000, () => clearVerifier(row.id)).catch((e) => + logger.error('Failed to clear PKCE verifier after MCP OAuth callback', { + error: toError(e).message, + }) + ) } if (result !== 'AUTHORIZED') { @@ -185,7 +236,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // forceRefresh: skip any stale cache from before re-auth. - await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true) + await timedStep('discoverServerTools', 60_000, () => + mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true) + ) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index 15860b3e6e0..a3c42f0f07e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -70,6 +70,7 @@ vi.mock('@sim/db/schema', () => ({ previewYaml: 'copilotChats.previewYaml', planArtifact: 'copilotChats.planArtifact', config: 'copilotChats.config', + deletedAt: 'copilotChats.deletedAt', }, workspaceFiles: { id: 'workspaceFiles.id', @@ -77,8 +78,10 @@ vi.mock('@sim/db/schema', () => ({ })) vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), })) vi.mock('@/lib/copilot/resources/persistence', () => ({ diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 9f8011bd264..ed88eed525e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { copilotChats, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' @@ -84,7 +84,7 @@ export const POST = withRouteHandler( config: copilotChats.config, }) .from(copilotChats) - .where(eq(copilotChats.id, chatId)) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.deletedAt))) .limit(1) if (!parent || parent.userId !== userId || parent.type !== 'mothership') { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts new file mode 100644 index 00000000000..31d49d8a653 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbSelect, + mockSelectFrom, + mockSelectWhere, + mockSelectLimit, + mockDbUpdate, + mockDbSet, + mockUpdateWhere, + mockDbReturning, + mockAssertActiveWorkspaceAccess, + mockPublishStatusChanged, +} = vi.hoisted(() => ({ + mockDbSelect: vi.fn(), + mockSelectFrom: vi.fn(), + mockSelectWhere: vi.fn(), + mockSelectLimit: vi.fn(), + mockDbUpdate: vi.fn(), + mockDbSet: vi.fn(), + mockUpdateWhere: vi.fn(), + mockDbReturning: vi.fn(), + mockAssertActiveWorkspaceAccess: vi.fn(), + mockPublishStatusChanged: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + update: mockDbUpdate, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + userId: 'copilotChats.userId', + type: 'copilotChats.type', + workspaceId: 'copilotChats.workspaceId', + updatedAt: 'copilotChats.updatedAt', + lastSeenAt: 'copilotChats.lastSeenAt', + deletedAt: 'copilotChats.deletedAt', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + ...copilotHttpMock, + createForbiddenResponse: vi.fn((message: string) => ({ + status: 403, + ok: false, + json: async () => ({ error: message }), + })), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError: (error: unknown) => + error instanceof Error && error.message === 'ACCESS_DENIED', +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +import { POST } from '@/app/api/mothership/chats/[chatId]/restore/route' + +function makeRequest(chatId: string) { + return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/restore`, { + method: 'POST', + }) +} + +function makeContext(chatId: string) { + return { params: Promise.resolve({ chatId }) } +} + +describe('POST /api/mothership/chats/[chatId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + mockSelectLimit.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) + mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) + mockDbSelect.mockReturnValue({ from: mockSelectFrom }) + mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + mockUpdateWhere.mockReturnValue({ returning: mockDbReturning }) + mockDbSet.mockReturnValue({ where: mockUpdateWhere }) + mockDbUpdate.mockReturnValue({ set: mockDbSet }) + mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + }) + + it('returns 401 when unauthenticated', async () => { + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ + userId: null, + isAuthenticated: false, + }) + + const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) + expect(response.status).toBe(401) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('returns 404 when no soft-deleted chat is owned by the caller', async () => { + mockSelectLimit.mockResolvedValueOnce([]) + + const response = await POST(makeRequest('chat-missing'), makeContext('chat-missing')) + expect(response.status).toBe(404) + expect(mockAssertActiveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('returns 403 when the caller lost access to the workspace', async () => { + mockAssertActiveWorkspaceAccess.mockRejectedValueOnce(new Error('ACCESS_DENIED')) + + const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) + expect(response.status).toBe(403) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('restores the chat, bumping updatedAt and lastSeenAt, and publishes the event', async () => { + const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(mockDbSet).toHaveBeenCalledWith({ + deletedAt: null, + updatedAt: expect.any(Date), + lastSeenAt: expect.any(Date), + }) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'created', + }) + }) + + it('returns 404 when the chat is restored concurrently before the update lands', async () => { + mockDbReturning.mockResolvedValueOnce([]) + + const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) + expect(response.status).toBe(404) + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts new file mode 100644 index 00000000000..b59bcceac05 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNotNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats' +import { parseRequest } from '@/lib/api/server' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + authenticateCopilotRequestSessionOnly, + createForbiddenResponse, + createInternalServerErrorResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + assertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError, +} from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreMothershipChatAPI') + +/** + * POST /api/mothership/chats/[chatId]/restore + * Restores a soft-deleted mothership chat back into the sidebar. Ownership is + * enforced by scoping the update to the authenticated user's rows, and the + * caller must still have access to the chat's workspace, matching the delete + * path. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const parsed = await parseRequest(restoreMothershipChatContract, request, context) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + + const [chat] = await db + .select({ workspaceId: copilotChats.workspaceId }) + .from(copilotChats) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.type, 'mothership'), + isNotNull(copilotChats.deletedAt) + ) + ) + .limit(1) + + if (!chat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + if (chat.workspaceId) { + await assertActiveWorkspaceAccess(chat.workspaceId, userId) + } + + // Bump `updatedAt` (like workflow/table/KB restores) so the restored chat + // surfaces at the top of the sidebar, and mark it seen for the restorer. + const now = new Date() + const [restoredChat] = await db + .update(copilotChats) + .set({ deletedAt: null, updatedAt: now, lastSeenAt: now }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.type, 'mothership'), + isNotNull(copilotChats.deletedAt) + ) + ) + .returning({ + workspaceId: copilotChats.workspaceId, + }) + + if (!restoredChat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + if (restoredChat.workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId: restoredChat.workspaceId, + chatId, + type: 'created', + }) + captureServerEvent( + userId, + 'task_restored', + { workspace_id: restoredChat.workspaceId }, + { + groups: { workspace: restoredChat.workspaceId }, + } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + if (isWorkspaceAccessDeniedError(error)) { + return createForbiddenResponse('Workspace access denied') + } + logger.error('Error restoring mothership chat:', error) + return createInternalServerErrorResponse('Failed to restore chat') + } + } +) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index 0879cf10aa6..095c5a11cc5 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -6,7 +6,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbDelete, + mockDbUpdate, + mockDbSet, mockDbReturning, mockDbWhere, mockDecrementStorageUsageForBillingContext, @@ -17,7 +18,8 @@ const { mockReadFilePreviewSessions, mockGetLatestRunForStream, } = vi.hoisted(() => ({ - mockDbDelete: vi.fn(), + mockDbUpdate: vi.fn(), + mockDbSet: vi.fn(), mockDbReturning: vi.fn(), mockDbWhere: vi.fn(), mockDecrementStorageUsageForBillingContext: vi.fn(), @@ -31,7 +33,7 @@ const { vi.mock('@sim/db', () => ({ db: { - delete: mockDbDelete, + update: mockDbUpdate, }, })) @@ -43,12 +45,14 @@ vi.mock('@sim/db/schema', () => ({ updatedAt: 'copilotChats.updatedAt', lastSeenAt: 'copilotChats.lastSeenAt', workspaceId: 'copilotChats.workspaceId', + deletedAt: 'copilotChats.deletedAt', }, })) vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), sql: Object.assign( vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', @@ -319,12 +323,13 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { type: 'mothership', workspaceId: 'workspace-1', }) - mockDbDelete.mockReturnValue({ where: mockDbWhere }) + mockDbUpdate.mockReturnValue({ set: mockDbSet }) + mockDbSet.mockReturnValue({ where: mockDbWhere }) mockDbWhere.mockReturnValue({ returning: mockDbReturning }) mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) }) - it('deletes an unbilled chat without decrementing workspace or payer storage', async () => { + it('soft-deletes an unbilled chat without decrementing workspace or payer storage', async () => { const response = await DELETE( new NextRequest('http://localhost:3000/api/mothership/chats/chat-delete', { method: 'DELETE', @@ -333,7 +338,8 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { ) expect(response.status).toBe(200) - expect(mockDbDelete).toHaveBeenCalled() + expect(mockDbUpdate).toHaveBeenCalled() + expect(mockDbSet).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 99176cd6b23..9fa07c1e067 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deleteMothershipChatContract, @@ -188,7 +188,8 @@ export const PATCH = withRouteHandler( and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), - eq(copilotChats.type, 'mothership') + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) ) ) .returning({ @@ -264,12 +265,14 @@ export const DELETE = withRouteHandler( } const [deletedChat] = await db - .delete(copilotChats) + .update(copilotChats) + .set({ deletedAt: new Date() }) .where( and( eq(copilotChats.id, chatId), eq(copilotChats.userId, userId), - eq(copilotChats.type, 'mothership') + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) ) ) .returning({ diff --git a/apps/sim/app/api/mothership/chats/route.test.ts b/apps/sim/app/api/mothership/chats/route.test.ts index f23d33034ea..890e228a0b5 100644 --- a/apps/sim/app/api/mothership/chats/route.test.ts +++ b/apps/sim/app/api/mothership/chats/route.test.ts @@ -31,6 +31,8 @@ vi.mock('@sim/db/schema', () => ({ updatedAt: 'copilotChats.updatedAt', conversationId: 'copilotChats.conversationId', lastSeenAt: 'copilotChats.lastSeenAt', + pinned: 'copilotChats.pinned', + deletedAt: 'copilotChats.deletedAt', }, })) @@ -38,6 +40,8 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), desc: vi.fn((field: unknown) => ({ type: 'desc', field })), eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), + isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 764b897eac3..8a9a60ef0fa 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -37,11 +37,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const queryResult = await parseRequest(listMothershipChatsContract, request, {}) if (!queryResult.success) return queryResult.response - const { workspaceId } = queryResult.data.query + const { workspaceId, scope } = queryResult.data.query await assertActiveWorkspaceAccess(workspaceId, userId) - const data = await listMothershipChats(userId, workspaceId) + const data = await listMothershipChats(userId, workspaceId, scope) return NextResponse.json({ success: true, data }) } catch (error) { diff --git a/apps/sim/app/api/tools/onepassword/utils.test.ts b/apps/sim/app/api/tools/onepassword/utils.test.ts index 4c07ede1323..8d9e9a76672 100644 --- a/apps/sim/app/api/tools/onepassword/utils.test.ts +++ b/apps/sim/app/api/tools/onepassword/utils.test.ts @@ -54,18 +54,35 @@ describe('validateConnectServerUrl', () => { }) it('blocks a hostname that resolves to a private IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) await expect(validateConnectServerUrl('https://connect.internal')).rejects.toThrow( 'cannot point to a private or reserved IP address' ) }) it('allows a hostname that resolves to a public IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( '93.184.216.34' ) }) + + it('prefers the IPv4 address for a dual-stack host (avoids unreachable IPv6 pin)', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '2606:4700::6810:85e5', family: 6 }, + { address: '93.184.216.34', family: 4 }, + ]) + await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( + '93.184.216.34' + ) + }) + + it('pins the sole IPv6 address for an IPv6-only host', async () => { + mockDnsLookup.mockResolvedValue([{ address: '2606:4700::6810:85e5', family: 6 }]) + await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( + '2606:4700::6810:85e5' + ) + }) }) describe('self-hosted deployment', () => { @@ -94,7 +111,7 @@ describe('validateConnectServerUrl', () => { }) it('allows a hostname that resolves to a private IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) await expect(validateConnectServerUrl('https://connect.internal')).resolves.toBe('10.1.2.3') }) }) diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index b78cf0d511c..0ecdd9abe50 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -317,7 +317,10 @@ export async function validateConnectServerUrl(serverUrl: string): Promise entry.family === 4) ?? resolved[0]).address } catch (error) { connectLogger.warn('DNS lookup failed for 1Password Connect server URL', { hostname: clean, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 971732b747c..abedf727bf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -17,7 +17,6 @@ import { extractTextContent } from '@/lib/core/utils/react-node-text' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { type ContentSegment, - PendingTagIndicator, parseSpecialTags, SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' @@ -396,8 +395,13 @@ interface ChatContentProps { onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void - /** Reports whether this segment is actively painting text or its own pending-tag indicator. */ + /** Reports whether this segment is actively painting text. */ onStreamActivityChange?: (active: boolean) => void + /** + * Reports whether a special tag is mid-stream — bytes arriving but rendering + * nothing (tags are suppressed until complete). A wait from the user's POV. + */ + onPendingTagChange?: (pending: boolean) => void } function ChatContentInner({ @@ -409,6 +413,7 @@ function ChatContentInner({ onWorkspaceResourceSelect, onRevealStateChange, onStreamActivityChange, + onPendingTagChange, }: ChatContentProps) { const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -530,12 +535,17 @@ function ChatContentInner({ () => parseSpecialTags(streamedContent, isRevealing), [streamedContent, isRevealing] ) - const hasPendingIndicator = parsed.hasPendingTag && isRevealing useEffect(() => { - onStreamActivityChange?.(hasRevealBacklog || hasPendingIndicator) + onStreamActivityChange?.(hasRevealBacklog) return () => onStreamActivityChange?.(false) - }, [hasPendingIndicator, hasRevealBacklog, onStreamActivityChange]) + }, [hasRevealBacklog, onStreamActivityChange]) + + const hasPendingTag = parsed.hasPendingTag && isRevealing + useEffect(() => { + onPendingTagChange?.(hasPendingTag) + return () => onPendingTagChange?.(false) + }, [hasPendingTag, onPendingTagChange]) type BlockSegment = Exclude< ContentSegment, @@ -624,7 +634,6 @@ function ChatContentInner({ /> ) })} - {hasPendingIndicator && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index a566cd3e9c2..f44aab8407f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -559,13 +559,18 @@ export function SpecialTags({ } } +interface PendingTagIndicatorProps { + /** Activity phrase next to the loader; crossfades on change. */ + label: string +} + /** - * Renders a "Thinking" shimmer while a special tag is still streaming in. + * Renders the turn-level activity shimmer. */ -export function PendingTagIndicator() { +export function PendingTagIndicator({ label }: PendingTagIndicatorProps) { return (
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 6f87cb1de0a..942ca3c5afe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -14,8 +14,8 @@ import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/s import type { ContentBlock } from '../../types' import { assistantMessageHasVisibleExecutingTool, + deriveThinkingLabel, parseBlocks, - shouldShowTrailingThinking, shouldSmoothTextSegment, } from './message-content' @@ -628,62 +628,6 @@ describe('narration text seams', () => { }) }) -describe('shouldShowTrailingThinking', () => { - it('shows one turn-level indicator while an open subagent waits between completed steps', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'agent_group', - }) - ).toBe(true) - }) - - it('stays hidden while a chunk is rendering or before the stream becomes idle', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: true, - hasExecutingTool: false, - lastSegmentType: 'text', - }) - ).toBe(false) - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: false, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'agent_group', - }) - ).toBe(false) - }) - - it('does not duplicate an executing tool row or survive a stopped turn', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: true, - lastSegmentType: 'agent_group', - }) - ).toBe(false) - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'stopped', - }) - ).toBe(false) - }) -}) - describe('parseBlocks legacy — thinking between top-level tools', () => { it('keeps consecutive mothership tools in one group across intervening thinking', () => { const blocks: ContentBlock[] = [ @@ -793,3 +737,27 @@ describe('assistantMessageHasVisibleExecutingTool', () => { expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) }) }) + +describe('deriveThinkingLabel', () => { + it('maps the most recent block to an activity phrase', () => { + expect(deriveThinkingLabel([])).toBe('Thinking…') + expect(deriveThinkingLabel([{ type: 'thinking', content: 'hm', timestamp: 1 }])).toBe( + 'Thinking…' + ) + // A stall after streamed text is the agent deciding what's next, not generating. + expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking…') + expect(deriveThinkingLabel([{ type: 'subagent_text', content: 'x', timestamp: 1 }])).toBe( + 'Thinking…' + ) + expect(deriveThinkingLabel([{ type: 'subagent_end', spanId: 'S1', timestamp: 1 }])).toBe( + 'Returning…' + ) + }) + + it('shows Dispatching for the dispatch call, then yields to the opened lane', () => { + expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…') + expect(deriveThinkingLabel([mainToolCall('t1', 'workspace_file')])).toBe('Dispatching…') + expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…') + expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 9d8f67067f8..2abcec9fad5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,6 +1,16 @@ 'use client' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { cn } from '@sim/emcn' import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' @@ -18,6 +28,7 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' +/** Quiet period before the shimmer takes the slot back from streamed output. */ const STREAM_IDLE_DELAY_MS = 1_500 interface TextSegment { @@ -738,47 +749,69 @@ export function shouldSmoothTextSegment({ return isStreaming && segmentIndex === segmentCount - 1 } -export function shouldShowTrailingThinking({ - isStreaming, - isStreamIdle, - isRenderingStream, - hasExecutingTool, - lastSegmentType, -}: { - isStreaming: boolean - isStreamIdle: boolean - isRenderingStream: boolean - hasExecutingTool: boolean - lastSegmentType?: 'text' | 'agent_group' | 'options' | 'stopped' -}): boolean { - return ( - isStreaming && - isStreamIdle && - !isRenderingStream && - !hasExecutingTool && - lastSegmentType !== 'stopped' - ) +const DISPATCH_TOOL_NAMES = new Set([...SUBAGENT_KEYS, ...Object.values(SUBAGENT_DISPATCH_TOOLS)]) + +/** + * Activity phrase for the turn-level shimmer, derived from the most recent + * stream block. The shimmer only shows in quiet gaps (see showShimmer), so the + * phrase describes the wait, not the output: a stall after streamed text is + * the agent deciding what's next — Thinking — never "Generating" (while text + * actually generates the shimmer is hidden). Dispatching covers only the + * dispatch call itself (whose tool row the parser absorbs, so nothing else + * shows); once the lane is open its own delegating shimmer owns the state and + * the turn-level one stays hidden (`null`). + */ +export function deriveThinkingLabel(blocks: ContentBlock[]): string | null { + const last = blocks[blocks.length - 1] + switch (last?.type) { + case 'subagent': + return null + case 'subagent_end': + return 'Returning…' + case 'tool_call': + return last.toolCall && DISPATCH_TOOL_NAMES.has(last.toolCall.name) + ? 'Dispatching…' + : 'Thinking…' + default: + return 'Thinking…' + } } interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string isStreaming: boolean + /** + * True for the last message in the transcript. The last turn keeps a + * fixed-height thinking slot at its bottom (see JSX) so the shimmer fades in + * place without ever changing height. + */ + isLast?: boolean /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onPhaseChange?: (phase: MessagePhase) => void + /** + * The message's actions row (copy/thumbs). Rendered here, in the thinking + * slot's position, so at settle the shimmer and the actions trade places in + * one render — a single tiny reflow instead of a collapse the buttons ride + * or a late mount the chase visibly scrolls to. The caller gates it on + * content/question eligibility only; the settle timing is owned here. + */ + actions?: ReactNode } function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + isLast = false, questionAnswers, onOptionSelect, onQuestionDismiss, onPhaseChange, + actions, }: MessageContentProps) { const { onWorkspaceResourceSelect } = useChatSurface() const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks]) @@ -791,6 +824,10 @@ function MessageContentInner({ const handleTrailingStreamActivityChange = useCallback((active: boolean) => { setTrailingStreamActivity(active) }, []) + const [trailingPendingTag, setTrailingPendingTag] = useState(false) + const handleTrailingPendingTagChange = useCallback((pending: boolean) => { + setTrailingPendingTag(pending) + }, []) const [isStreamIdle, setIsStreamIdle] = useState(false) const segments: MessageSegment[] = @@ -802,8 +839,8 @@ function MessageContentInner({ const visibleStreamActivityKey = getVisibleStreamActivityKey(segments) // Every visible stream update restarts the quiet-period clock. A layout - // effect clears an already-visible indicator before paint, so a chunk from - // any parallel lane hides the one turn-level loader without a stale flash. + // effect clears an already-visible shimmer before paint, so a chunk from any + // parallel lane yields the slot to the arriving output without a stale flash. useLayoutEffect(() => { if (!isStreaming) { setIsStreamIdle(false) @@ -826,91 +863,117 @@ function MessageContentInner({ onPhaseChangeRef.current?.(phase) }, [phase]) - if (segments.length === 0) { - if (isStreaming) { - return ( -
- -
- ) - } - return null - } + // The slot is the last message's own element, so it grows on send with the + // row (no separate mount → no jump). Gated on phase, not isStreaming: the + // trailing text keeps visually revealing on a timer after the network stream + // closes, and collapsing under a still-growing reveal reads as the blob + // winking out early while everything shifts. + const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped' - // Executing tools already render an active row. An open subagent lane does - // not suppress the turn-level indicator: once its latest visible chunk has - // settled, the loader can bridge the wait until that lane (or a parallel - // sibling) emits again. + if (segments.length === 0 && !isLast) return null + + // A visible executing tool row already spins — the turn-level shimmer would + // double it. (A null label means a just-opened lane's shimmer owns the state.) + // A mid-stream special tag renders nothing until complete, so its bytes are a + // wait, not output — the shimmer bridges it without the quiet-period delay. + const thinkingLabel = deriveThinkingLabel(blocks) const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) - const showTrailingThinking = shouldShowTrailingThinking({ - isStreaming: phase === 'streaming', - isStreamIdle, - isRenderingStream: trailingStreamActivity, - hasExecutingTool, - lastSegmentType: lastSegment.type, - }) + const showShimmer = + thinkingExpanded && + thinkingLabel !== null && + (segments.length === 0 || + trailingPendingTag || + (isStreamIdle && !trailingStreamActivity && !hasExecutingTool)) return ( -
- {segments.map((segment, i) => { - switch (segment.type) { - case 'text': - return ( - - ) - case 'agent_group': { - return ( -
- +
+ {segments.map((segment, i) => { + switch (segment.type) { + case 'text': + return ( + -
- ) + ) + case 'agent_group': { + return ( +
+ +
+ ) + } + case 'options': + return ( +
+ +
+ ) + case 'stopped': + return ( +
+ + Stopped by user +
+ ) } - case 'options': - return ( -
- -
- ) - case 'stopped': - return ( -
- - Stopped by user -
- ) - } - })} - {showTrailingThinking && } + })} +
+ {thinkingExpanded && isLast ? ( + // Fixed-height placeholder for the NEXT piece of output: the shimmer + // and arriving output trade places via opacity only, so mid-turn swaps + // can't move layout. A sibling of the space-y stack (not a child), so + // it carries no stray sibling margin — pt-[10px] is its own gap. +
+
+ +
+
+ ) : ( + // The actions row takes the slot's place in the SAME render — a single + // ~10px reflow instead of a collapse the buttons would ride upward or a + // late mount the chase would visibly scroll to. + actions &&
{actions}
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index e61e9af3c47..db903e8398a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -21,10 +21,7 @@ import { type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { - PendingTagIndicator, - parseLastQuestionTag, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { parseLastQuestionTag } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -101,7 +98,6 @@ const OVERSCAN = 6 * scrolled up. */ const PIN_THRESHOLD = 2 - /** * Initial-scroll sentinel. Distinct from every real `chatId` value — including * `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires @@ -113,7 +109,7 @@ const UNSCROLLED = Symbol('unscrolled') const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: - 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]', + 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]', sizer: 'relative mx-auto w-full max-w-[48rem]', rowGap: 'pb-6', userRow: 'flex flex-col items-end gap-[6px] pt-3', @@ -175,6 +171,7 @@ const UserMessageRow = memo(function UserMessageRow({ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean + isLast: boolean precedingUserContent?: string /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] @@ -186,6 +183,7 @@ interface AssistantMessageRowProps { const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, + isLast, precedingUserContent, questionAnswers, rowClassName, @@ -205,10 +203,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ onAnimatingChangeRef.current?.(phase !== 'settled') }, [phase]) - if (!hasAnyBlocks && !trimmedContent && isStreaming) { - return - } - const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '') if (!hasRenderableAssistant && !trimmedContent && !isStreaming) { return null @@ -225,8 +219,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ const handleQuestionDismiss = () => { if (questionTag) setDismissedQuestionTag(questionTag) } - const showActions = shouldShowAssistantMessageActions({ - phase, + // Settle timing lives in MessageContent (the actions take the thinking + // slot's place in the same render), so eligibility here is phase-free: + // `phase: 'settled'` asks the helper "would a settled turn show them?". + const actionsEligible = shouldShowAssistantMessageActions({ + phase: 'settled', hasContent: Boolean(message.content) || hasAnyBlocks, endsWithQuestion, questionDismissed, @@ -245,21 +242,22 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + isLast={isLast} questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onQuestionDismiss={handleQuestionDismiss} onPhaseChange={setPhase} + actions={ + actionsEligible ? ( + + ) : undefined + } /> - {showActions && ( -
- -
- )} ) }) @@ -302,6 +300,45 @@ export function MothershipChat({ const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) + const sizerRef = useRef(null) + const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) + + /** + * Sizer floor while streaming: `scrollHeight` must never dip below the + * current viewport bottom. Streaming markdown re-parse emits transient + * row-height shrinks; when they pull scrollHeight under + * `scrollTop + clientHeight`, the browser clamps `scrollTop` and the pinned + * transcript visibly drops, then the chase glides it back. Flooring the + * sizer at exactly the scrolled-to extent prevents that clamp while never + * ADDING space — the floor cannot exceed what is already on screen. So an + * estimate correction (a fresh row measuring smaller than + * ROW_HEIGHT_ESTIMATE) releases immediately instead of holding phantom space + * the chase would scroll into and bounce back out of. + * + * Active on the same signal as auto-scroll: the reveal keeps re-parsing + * markdown (and shrinking) after the network stream closes, so the floor + * must hold through `lastRowAnimating` too. + */ + const floorActive = isStreamActive || lastRowAnimating + useLayoutEffect(() => { + const sizer = sizerRef.current + const el = scrollElementRef.current + if (!sizer || !el) return + if (!floorActive) { + sizer.style.minHeight = '' + return + } + if (!scrollerPaddingRef.current) { + const style = getComputedStyle(el) + scrollerPaddingRef.current = { + top: Number.parseFloat(style.paddingTop), + bottom: Number.parseFloat(style.paddingBottom), + } + } + const padding = scrollerPaddingRef.current + const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom) + sizer.style.minHeight = `${floor}px` + }) const setScrollElement = useCallback( (el: HTMLDivElement | null) => { scrollElementRef.current = el @@ -516,7 +553,11 @@ export function MothershipChat({ {isLoading && !hasMessages ? ( ) : ( -
+
{virtualItems.map((virtualItem) => { const index = virtualItem.index const msg = messages[index] @@ -544,6 +585,7 @@ export function MothershipChat({ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 6a875d4a824..696c3fd82c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -8,7 +8,6 @@ import { truncate } from '@sim/utils/string' import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { SearchHighlight } from '@/components/ui/search-highlight' import type { ChunkData } from '@/lib/knowledge/types' import { formatTokenCount } from '@/lib/tokenization' import type { @@ -38,7 +37,7 @@ import { documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' -import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 205de1bd764..f5f988ec658 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -31,7 +31,6 @@ import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' -import { SearchHighlight } from '@/components/ui/search-highlight' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' @@ -59,6 +58,7 @@ import { ConnectorsSection, DocumentContextMenu, RenameDocumentModal, + SearchHighlight, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { addConnectorParam, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index d26e85dc9e3..12e32ebf736 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -6,3 +6,4 @@ export { ConnectorsSection } from './connectors-section' export { DocumentContextMenu } from './document-context-menu' export { EditConnectorModal } from './edit-connector-modal' export { RenameDocumentModal } from './rename-document-modal' +export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts new file mode 100644 index 00000000000..1144ed165cd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts @@ -0,0 +1 @@ +export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/components/ui/search-highlight.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx similarity index 85% rename from apps/sim/components/ui/search-highlight.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx index 050ffc87b85..41062a49b00 100644 --- a/apps/sim/components/ui/search-highlight.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx @@ -4,6 +4,11 @@ interface SearchHighlightProps { className?: string } +/** + * Renders `text` with substrings matching any whitespace-separated term of + * `searchQuery` wrapped in the highlight-match colors. Falls back to plain + * text when the query is empty. + */ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHighlightProps) { if (!searchQuery.trim()) { return {text} diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index e54318c55a5..f8e2d3c3afe 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -75,7 +75,7 @@ export async function prefetchWorkspaceSidebar( staleTime: WORKFLOW_LIST_STALE_TIME, }), queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.list(workspaceId), + queryKey: mothershipChatKeys.list(workspaceId, 'active'), queryFn: async () => { const data = await listMothershipChats(userId, workspaceId) return data.map(mapChat) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index fe6d59e0967..f3afefe0820 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -116,7 +116,7 @@ function getTestButtonLabel( if (testResult?.success) return 'Connection success' if (testResult?.authRequired) return 'Requires OAuth' if (testResult && !testResult.success) return 'No connection: retry' - return 'Test Connection' + return 'Test connection' } interface FormattedInputProps { @@ -609,8 +609,8 @@ export function McpServerFormModal({ const isSubmitDisabled = isSubmitting || !isFormValid || isDomainBlocked || (mode === 'edit' && !hasChanges) - const title = mode === 'add' ? 'Add New MCP Server' : 'Edit MCP Server' - const submitLabel = mode === 'add' ? 'Add MCP' : 'Save' + const title = mode === 'add' ? 'Add MCP server' : 'Edit MCP server' + const submitLabel = mode === 'add' ? 'Add server' : 'Save' const handleToggleJsonMode = () => { if (testResult) clearTestResult() @@ -622,7 +622,7 @@ export function McpServerFormModal({ const secondaryAction: ChipModalFooterAction | undefined = mode === 'add' ? { - label: formMode === 'form' ? 'Edit JSON' : 'Edit Form', + label: formMode === 'form' ? 'Edit JSON' : 'Edit form', onClick: handleToggleJsonMode, } : formMode === 'form' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 92f3fc7f60a..5558e23fb35 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -68,10 +68,13 @@ interface ServerListItemProps { server: McpServer tools: McpTool[] isDeleting: boolean + isConnecting: boolean isLoadingTools?: boolean isRefreshing?: boolean + discoveryError?: string | null onRemove: () => void onViewDetails: () => void + onAuthorize: () => void } function ServerListItem({ @@ -79,10 +82,13 @@ function ServerListItem({ server, tools, isDeleting, + isConnecting, isLoadingTools = false, isRefreshing = false, + discoveryError = null, onRemove, onViewDetails, + onAuthorize, }: ServerListItemProps) { const transportLabel = formatTransportLabel(server.transport || 'http') const toolsLabel = getServerToolsLabel( @@ -91,32 +97,51 @@ function ServerListItem({ server.lastError, server.authType ) + // Only hard-red when there are no last-known tools to show. A populated, connected server + // stays on its tool count through a transient probe failure; a persistent failure flips + // `connectionStatus` to error/disconnected and reads as failed through that path instead. + const showDiscoveryError = + Boolean(discoveryError) && + tools.length === 0 && + server.connectionStatus !== 'error' && + server.connectionStatus !== 'disconnected' const hasConnectionIssue = - server.connectionStatus === 'error' || server.connectionStatus === 'disconnected' + server.connectionStatus === 'error' || + server.connectionStatus === 'disconnected' || + showDiscoveryError return (
- {server.name || 'Unnamed Server'} + {server.name || 'Unnamed server'} ({transportLabel})

- {isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : toolsLabel} + {isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : toolsLabel}

+ {canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( + {isConnecting ? 'Reopen authorization' : 'Authorize'} + )} 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 @@ -385,15 +409,13 @@ export function MCP() { const refreshAction = getRefreshActionState({ mutationStatus: isCurrentRefresh ? refreshServerMutation.status : 'idle', connectionStatus: isCurrentRefresh ? refreshServerMutation.data?.status : undefined, - authType: server.authType, - error: isCurrentRefresh ? refreshServerMutation.data?.error : undefined, workflowsUpdated: isCurrentRefresh ? refreshServerMutation.data?.workflowsUpdated : undefined, }) return (
- Server Name -

{server.name || 'Unnamed Server'}

+ Server name +

{server.name || 'Unnamed server'}

@@ -450,12 +472,11 @@ export function MCP() {
{ await startOauthForServer(server.id) }} > - {connectingOauthServers.has(server.id) ? 'Connecting…' : 'Connect with OAuth'} + {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'}
@@ -617,7 +638,7 @@ export function MCP() { search={{ value: searchTerm, onChange: setSearchTerm, - placeholder: 'Search MCPs...', + placeholder: 'Search servers...', }} actions={ canEdit @@ -633,13 +654,15 @@ export function MCP() { : [] } > - {error ? ( + {listError ? (
-

- {getErrorMessage(error, 'Failed to load MCP servers')} +

+ {getErrorMessage(listError, 'Failed to load MCP servers')}

- ) : serversLoading ? null : !hasServers ? ( + ) : serversLoading ? ( + Loading... + ) : !hasServers ? ( {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} @@ -660,13 +683,18 @@ export function MCP() { server={server} tools={tools} isDeleting={deletingServers.has(server.id)} + isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} isRefreshing={ refreshServerMutation.isPending && refreshServerMutation.variables?.serverId === server.id } + discoveryError={ + serverToolsState?.error ? getErrorMessage(serverToolsState.error) : null + } onRemove={() => handleRemoveServer(server.id)} onViewDetails={() => handleViewDetails(server.id)} + onAuthorize={() => startOauthForServer(server.id)} /> ) })} @@ -705,8 +733,8 @@ export function MCP() { onOpenChange={(open) => { if (!open) setServerToDeleteId(null) }} - srTitle='Delete MCP Server' - title='Delete MCP Server' + srTitle='Delete MCP server' + title='Delete MCP server' text={[ 'Are you sure you want to delete ', { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts index 4a036e8827b..3186429218a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.test.ts @@ -31,28 +31,11 @@ describe('getRefreshActionState', () => { }) }) - it('shows OAuth authorization required when an OAuth refresh finishes disconnected', () => { - expect( - getRefreshActionState({ - mutationStatus: 'success', - connectionStatus: 'disconnected', - authType: 'oauth', - workflowsUpdated: 0, - }) - ).toEqual({ - text: 'OAuth authorization required', - textTone: 'error', - disabled: false, - }) - }) - it('keeps Failed when a disconnected OAuth refresh has a concrete error', () => { expect( getRefreshActionState({ mutationStatus: 'success', connectionStatus: 'disconnected', - authType: 'oauth', - error: 'The MCP server took too long to respond and timed out', workflowsUpdated: 0, }) ).toEqual({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts index 8b4ae5da87d..28a6869921e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state.ts @@ -1,12 +1,10 @@ import type { MutationStatus } from '@tanstack/react-query' -import type { McpServer, RefreshMcpServerResult } from '@/lib/api/contracts/mcp' +import type { RefreshMcpServerResult } from '@/lib/api/contracts/mcp' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' interface RefreshActionStateInput { mutationStatus: MutationStatus connectionStatus?: RefreshMcpServerResult['status'] - authType?: McpServer['authType'] - error?: RefreshMcpServerResult['error'] workflowsUpdated?: number } @@ -15,23 +13,12 @@ type RefreshActionState = Pick export function getRefreshActionState({ mutationStatus, connectionStatus, - authType, - error, workflowsUpdated, }: RefreshActionStateInput): RefreshActionState { if (mutationStatus === 'pending') { return { text: 'Refreshing...', textTone: undefined, disabled: true } } - if ( - mutationStatus === 'success' && - connectionStatus === 'disconnected' && - authType === 'oauth' && - !error?.trim() - ) { - return { text: 'OAuth authorization required', textTone: 'error', disabled: false } - } - if ( mutationStatus === 'error' || (mutationStatus === 'success' && connectionStatus !== 'connected') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts index 373ae480af4..43facb9bceb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.test.ts @@ -19,7 +19,7 @@ describe('getServerToolsLabel', () => { }) it('keeps the generic disconnected state for non-OAuth servers', () => { - expect(getServerToolsLabel([], 'disconnected', null, 'headers')).toBe('Not Connected') + expect(getServerToolsLabel([], 'disconnected', null, 'headers')).toBe('Not connected') }) it('shows the persisted error for disconnected connections', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts index 495cea5f9ff..2aae09d5537 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label.ts @@ -16,7 +16,7 @@ export function getServerToolsLabel( if (connectionStatus === 'disconnected') { return ( - lastError?.trim() || (authType === 'oauth' ? 'OAuth authorization required' : 'Not Connected') + lastError?.trim() || (authType === 'oauth' ? 'OAuth authorization required' : 'Not connected') ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index 7c86d4721ff..b61f8353a24 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Badge, Button, ChipInput, ChipSelect, cn, Label, Skeleton } from '@sim/emcn' +import { Badge, Button, ChipInput, ChipModalTabs, ChipSelect, Label, Skeleton } from '@sim/emcn' import { formatDateTime } from '@sim/utils/formatting' import { useQueryStates } from 'nuqs' import { AnthropicIcon, OpenAIIcon } from '@/components/icons' @@ -115,26 +115,11 @@ export function Mothership() { />
-
- {TABS.map((tab) => ( - - ))} -
+ ({ value: tab.id, label: tab.label }))} + value={activeTab} + onChange={(value) => setMothershipParams({ tab: value as MothershipTab })} + />
@@ -281,7 +266,7 @@ function OverviewTab({ )} {breakdown?.users && (
-
+
User ID Requests Cost @@ -296,7 +281,7 @@ function OverviewTab({ }) => (
{u.user_id} @@ -328,7 +313,7 @@ function OverviewTab({ {requests?.requests && (
-
+
Request ID Model Duration @@ -352,9 +337,9 @@ function OverviewTab({ }) => (
- + {r.request_id ?? '—'} @@ -452,7 +437,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
{generatedKey && ( -
+

License key (only shown once):

@@ -479,7 +464,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { {data?.licenses && (
-
+
Name Validations Expiration @@ -498,7 +483,7 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { }) => (
{lic.name} @@ -529,7 +514,7 @@ function StatCard({ loading?: boolean }) { return ( -
+

{label}

{loading ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index fc335655055..134f008f92e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -23,6 +23,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { useMothershipChats, useRestoreMothershipChat } from '@/hooks/queries/mothership-chats' import { useRestoreTable, useTablesList } from '@/hooks/queries/tables' import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows' import { @@ -43,6 +44,7 @@ type ResourceType = | 'file' | 'folder' | 'workspace_folder' + | 'chat' function getResourceHref( workspaceId: string, @@ -63,6 +65,8 @@ function getResourceHref( return `${base}/w` case 'workspace_folder': return `${base}/files?folderId=${id}` + case 'chat': + return `${base}/chat/${id}` } } @@ -81,6 +85,7 @@ const RESOURCE_TYPE_TO_MOTHERSHIP: Record, Mothersh table: 'table', knowledge: 'knowledgebase', file: 'file', + chat: 'task', } interface DeletedResource { @@ -103,6 +108,7 @@ const TABS: { id: ResourceType; label: string }[] = [ { id: 'table', label: 'Tables' }, { id: 'knowledge', label: 'Knowledge Bases' }, { id: 'file', label: 'Files' }, + { id: 'chat', label: 'Chats' }, ] const TYPE_LABEL: Record, string> = { @@ -112,6 +118,7 @@ const TYPE_LABEL: Record, string> = { table: 'Table', knowledge: 'Knowledge Base', file: 'File', + chat: 'Chat', } function ResourceIcon({ resource }: { resource: DeletedResource }) { @@ -167,6 +174,7 @@ export function RecentlyDeleted() { const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') + const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' }) const restoreWorkflow = useRestoreWorkflow() const restoreFolder = useRestoreFolder() @@ -174,6 +182,7 @@ export function RecentlyDeleted() { const restoreKnowledgeBase = useRestoreKnowledgeBase() const restoreWorkspaceFile = useRestoreWorkspaceFile() const restoreWorkspaceFileFolder = useRestoreWorkspaceFileFolder() + const restoreChat = useRestoreMothershipChat(workspaceId) const isLoading = workflowsQuery.isLoading || @@ -181,7 +190,8 @@ export function RecentlyDeleted() { tablesQuery.isLoading || knowledgeQuery.isLoading || filesQuery.isLoading || - workspaceFoldersQuery.isLoading + workspaceFoldersQuery.isLoading || + chatsQuery.isLoading const error = workflowsQuery.error || @@ -189,7 +199,8 @@ export function RecentlyDeleted() { tablesQuery.error || knowledgeQuery.error || filesQuery.error || - workspaceFoldersQuery.error + workspaceFoldersQuery.error || + chatsQuery.error const resources = useMemo(() => { const items: DeletedResource[] = [] @@ -254,6 +265,17 @@ export function RecentlyDeleted() { }) } + for (const chat of chatsQuery.data ?? []) { + if (!chat.deletedAt) continue + items.push({ + id: chat.id, + name: chat.name, + type: 'chat', + deletedAt: chat.deletedAt, + workspaceId, + }) + } + return items }, [ workflowsQuery.data, @@ -262,6 +284,7 @@ export function RecentlyDeleted() { knowledgeQuery.data, filesQuery.data, workspaceFoldersQuery.data, + chatsQuery.data, workspaceId, ]) @@ -359,6 +382,9 @@ export function RecentlyDeleted() { folderId: resource.id, }) break + case 'chat': + await restoreChat.mutateAsync(resource.id) + break } setRestoredItems((prev) => new Map(prev).set(resource.id, { resource, displayIndex })) @@ -419,6 +445,10 @@ export function RecentlyDeleted() { {filtered.map((resource) => { const isRestoring = restoringIds.has(resource.id) const isRestored = restoredItems.has(resource.id) + // Chats are per-user (the archived list and restore are scoped to the + // viewer's own rows), so chat restore skips the workspace edit gate — + // mirroring that any member can delete their own chats. + const canRestore = resource.type === 'chat' || canEdit return ( } trailing={ - !canEdit ? null : isRestoring ? ( + !canRestore ? null : isRestoring ? ( Restoring... diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index 703359ce299..a7e8435eed9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -9,6 +9,7 @@ export const RECENTLY_DELETED_TABS = [ 'table', 'knowledge', 'file', + 'chat', ] as const export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 43b1ea42d11..2d2f1d44b14 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -33,6 +33,7 @@ import { useUpdateChat, } from '@/hooks/queries/chats' import type { ChatDetail } from '@/hooks/queries/deployments' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useIdentifierValidation } from './hooks' import { getPasswordHelperText, @@ -371,6 +372,7 @@ export function ChatDeploy({ updateField('authType', type)} @@ -583,6 +585,8 @@ function IdentifierInput({ interface AuthSelectorProps { authType: AuthType + /** The persisted mode of an existing chat, kept selectable even if newly disallowed. */ + savedAuthType?: AuthType password: string emails: string[] onAuthTypeChange: (type: AuthType) => void @@ -602,6 +606,7 @@ const AUTH_LABELS: Record = { function AuthSelector({ authType, + savedAuthType, password, emails, onAuthTypeChange, @@ -671,10 +676,26 @@ function AuthSelector({ } } - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) - const authOptions = ssoEnabled - ? (['public', 'password', 'email', 'sso'] as const) - : (['public', 'password', 'email'] as const) + const { config: permissionConfig } = usePermissionConfig() + const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes + + const ssoAvailable = + isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || + savedAuthType === 'sso' || + (allowedAuthTypes?.includes('sso') ?? false) + const baseAuthOptions: AuthType[] = ssoAvailable + ? ['public', 'password', 'email', 'sso'] + : ['public', 'password', 'email'] + + const authOptions = baseAuthOptions.filter( + (type) => allowedAuthTypes === null || allowedAuthTypes.includes(type) || type === savedAuthType + ) + + useEffect(() => { + if (authOptions.length > 0 && !authOptions.includes(authType)) { + onAuthTypeChange(authOptions[0]) + } + }, [authOptions, authType, onAuthTypeChange]) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 3af6d13baf0..aa33d96cc92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -32,7 +32,6 @@ import { releaseDeployAction, tryAcquireDeployAction, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/deploy-action-lock' -import { syncLocalDraftFromServer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft' import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness' import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' import { normalizeName, startsWithUuid } from '@/executor/constants' @@ -52,6 +51,7 @@ import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { syncLocalDraftFromServer } from '@/stores/workflows/sync-local-draft' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.test.ts deleted file mode 100644 index 6abc9a694d0..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockRequestJson, - mockApplyWorkflowStateToStores, - mockGetRegistryState, - mockHasPendingOperations, - mockGetOperationQueueState, - mockGetWorkflowDiffState, -} = vi.hoisted(() => ({ - mockRequestJson: vi.fn(), - mockApplyWorkflowStateToStores: vi.fn(), - mockGetRegistryState: vi.fn(() => ({ activeWorkflowId: 'workflow-a' })), - mockHasPendingOperations: vi.fn(() => false), - mockGetOperationQueueState: vi.fn(() => ({ - hasPendingOperations: mockHasPendingOperations, - workflowOperationVersions: {}, - })), - mockGetWorkflowDiffState: vi.fn(() => ({ - hasActiveDiff: false, - pendingExternalUpdates: {}, - reconcilingWorkflows: {}, - reconciliationErrors: {}, - remoteUpdateVersions: {}, - })), -})) - -vi.mock('@/lib/api/client/request', () => ({ - requestJson: mockRequestJson, -})) - -vi.mock('@/lib/api/contracts', () => ({ - getWorkflowStateContract: {}, -})) - -vi.mock('@/stores/workflow-diff/utils', () => ({ - applyWorkflowStateToStores: mockApplyWorkflowStateToStores, -})) - -vi.mock('@/stores/workflow-diff/store', () => ({ - useWorkflowDiffStore: { - getState: mockGetWorkflowDiffState, - }, -})) - -vi.mock('@/stores/operation-queue/store', () => ({ - useOperationQueueStore: { - getState: mockGetOperationQueueState, - }, -})) - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: mockGetRegistryState, - }, -})) - -import { syncLocalDraftFromServer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft' - -describe('syncLocalDraftFromServer', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetRegistryState.mockReturnValue({ activeWorkflowId: 'workflow-a' }) - mockHasPendingOperations.mockReturnValue(false) - mockGetOperationQueueState.mockImplementation(() => ({ - hasPendingOperations: mockHasPendingOperations, - workflowOperationVersions: {}, - })) - mockGetWorkflowDiffState.mockReturnValue({ - hasActiveDiff: false, - pendingExternalUpdates: {}, - reconcilingWorkflows: {}, - reconciliationErrors: {}, - remoteUpdateVersions: {}, - }) - }) - - it('hydrates sibling workflow variables into the applied workflow state', async () => { - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - variables: { - 'variable-a': { - id: 'variable-a', - name: 'API_KEY', - type: 'plain', - value: 'secret', - }, - }, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(true) - - expect(mockApplyWorkflowStateToStores).toHaveBeenCalledWith( - 'workflow-a', - expect.objectContaining({ - variables: { - 'variable-a': { - id: 'variable-a', - name: 'API_KEY', - type: 'plain', - value: 'secret', - }, - }, - }), - { updateLastSaved: true } - ) - }) - - it('does not apply a fetched draft after navigation changes the active workflow', async () => { - mockGetRegistryState - .mockReturnValueOnce({ activeWorkflowId: 'workflow-a' }) - .mockReturnValueOnce({ activeWorkflowId: 'workflow-b' }) - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - variables: {}, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(false) - - expect(mockApplyWorkflowStateToStores).not.toHaveBeenCalled() - }) - - it('does not synthesize an empty variables object when the server omits variables', async () => { - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(true) - - const appliedState = mockApplyWorkflowStateToStores.mock.calls[0][1] - expect(Object.hasOwn(appliedState, 'variables')).toBe(false) - }) - - it('does not apply a fetched draft over newly queued local operations', async () => { - mockHasPendingOperations.mockReturnValueOnce(false).mockReturnValueOnce(true) - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - variables: {}, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(false) - - expect(mockApplyWorkflowStateToStores).not.toHaveBeenCalled() - }) - - it('does not apply a fetched draft when a newer remote update arrives during fetch', async () => { - mockGetWorkflowDiffState - .mockReturnValueOnce({ - hasActiveDiff: false, - pendingExternalUpdates: {}, - reconcilingWorkflows: {}, - reconciliationErrors: {}, - remoteUpdateVersions: {}, - }) - .mockReturnValueOnce({ - hasActiveDiff: false, - pendingExternalUpdates: {}, - reconcilingWorkflows: {}, - reconciliationErrors: {}, - remoteUpdateVersions: { 'workflow-a': 1 }, - }) - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - variables: {}, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(false) - - expect(mockApplyWorkflowStateToStores).not.toHaveBeenCalled() - }) - - it('does not apply a fetched draft when local operations queue and drain during fetch', async () => { - mockGetOperationQueueState - .mockReturnValueOnce({ - hasPendingOperations: mockHasPendingOperations, - workflowOperationVersions: {}, - }) - .mockReturnValueOnce({ - hasPendingOperations: mockHasPendingOperations, - workflowOperationVersions: {}, - }) - .mockReturnValueOnce({ - hasPendingOperations: mockHasPendingOperations, - workflowOperationVersions: { 'workflow-a': 1 }, - }) - mockRequestJson.mockResolvedValue({ - data: { - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: 1, - }, - variables: {}, - }, - }) - - await expect(syncLocalDraftFromServer('workflow-a')).resolves.toBe(false) - - expect(mockApplyWorkflowStateToStores).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.ts deleted file mode 100644 index ad308ee549c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/sync-local-draft.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { getWorkflowStateContract } from '@/lib/api/contracts' -import { useOperationQueueStore } from '@/stores/operation-queue/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { applyWorkflowStateToStores } from '@/stores/workflow-diff/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -function canApplyServerSnapshot( - workflowId: string, - remoteVersionAtStart: number, - localOperationVersionAtStart: number -): boolean { - if (useWorkflowRegistry.getState().activeWorkflowId !== workflowId) return false - const operationQueueState = useOperationQueueStore.getState() - if (operationQueueState.hasPendingOperations(workflowId)) return false - if ( - (operationQueueState.workflowOperationVersions[workflowId] ?? 0) !== - localOperationVersionAtStart - ) { - return false - } - - const diffState = useWorkflowDiffStore.getState() - return ( - !diffState.hasActiveDiff && - !diffState.pendingExternalUpdates[workflowId] && - !diffState.reconcilingWorkflows[workflowId] && - !diffState.reconciliationErrors[workflowId] && - (diffState.remoteUpdateVersions[workflowId] ?? 0) === remoteVersionAtStart - ) -} - -export async function syncLocalDraftFromServer(workflowId: string): Promise { - if (useWorkflowRegistry.getState().activeWorkflowId !== workflowId) return false - if (useOperationQueueStore.getState().hasPendingOperations(workflowId)) return false - const localOperationVersionAtStart = - useOperationQueueStore.getState().workflowOperationVersions[workflowId] ?? 0 - const remoteVersionAtStart = useWorkflowDiffStore.getState().remoteUpdateVersions[workflowId] ?? 0 - - const responseData = await requestJson(getWorkflowStateContract, { - params: { id: workflowId }, - }) - const wireState = responseData.data?.state - if (!canApplyServerSnapshot(workflowId, remoteVersionAtStart, localOperationVersionAtStart)) { - return false - } - if (!wireState) { - throw new Error('No workflow state was returned while syncing the local draft') - } - - // double-cast-allowed: workflowStateSchema is a wire supertype; normalized workflow state is persisted in store-compatible shape - const workflowState = wireState as unknown as WorkflowState - if (Object.hasOwn(responseData.data, 'variables')) { - workflowState.variables = responseData.data.variables || {} - } - applyWorkflowStateToStores(workflowId, workflowState, { updateLastSaved: true }) - return true -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index d0e0b867bfe..4909fd4e467 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -5,10 +5,10 @@ import { toError } from '@sim/utils/errors' import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { syncLocalDraftFromServer } from '@/stores/workflows/sync-local-draft' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { releaseDeployAction, tryAcquireDeployAction } from './deploy-action-lock' -import { syncLocalDraftFromServer } from './sync-local-draft' import type { DeployReadiness } from './use-deploy-readiness' const logger = createLogger('UseDeployment') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index c30f39fc074..8611bf7ac72 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Button, Combobox } from '@sim/emcn' +import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn' import { ExternalLink, KeyRound } from 'lucide-react' import { useParams } from 'next/navigation' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' @@ -18,7 +18,6 @@ import { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' -import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -53,8 +52,7 @@ export function CredentialSelector({ const workspaceId = (params?.workspaceId as string) || '' const [showConnectModal, setShowConnectModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false) - const [showSlackBotModal, setShowSlackBotModal] = useState(false) - const [showServiceAccountModal, setShowServiceAccountModal] = useState(false) + const [showSetupModal, setShowSetupModal] = useState(false) const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -104,24 +102,32 @@ export function CredentialSelector({ const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading const credentialKind = subBlock.credentialKind + const isMergedKinds = credentialKind === 'any' const credentials = useMemo(() => { - // A custom-bot or service-account picker lists only the reusable - // service-account credentials, including in trigger mode. - if (credentialKind === 'custom-bot' || credentialKind === 'service-account') { + // A service-account picker lists only the reusable service-account + // credentials, including in trigger mode. A merged ('any') picker lists + // OAuth accounts and service accounts together. + if (credentialKind === 'service-account') { return rawCredentials.filter((cred) => cred.type === 'service_account') } + if (isMergedKinds) { + return rawCredentials + } return isTriggerMode && !subBlock.allowServiceAccounts ? rawCredentials.filter((cred) => cred.type !== 'service_account') : rawCredentials - }, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts]) + }, [rawCredentials, isTriggerMode, credentialKind, isMergedKinds, subBlock.allowServiceAccounts]) - // Resolved service-account provider metadata for the token-paste connect - // modal. Gated on `credentialKind` and using the non-throwing lookup so it - // never runs (or throws) for the OAuth / custom-bot pickers. + // Resolved service-account provider metadata for the setup modal. Gated on + // `credentialKind` and using the non-throwing lookup so it never runs (or + // throws) for plain OAuth pickers. const serviceAccountService = useMemo( - () => (credentialKind === 'service-account' ? getServiceConfigByServiceId(serviceId) : null), - [credentialKind, serviceId] + () => + credentialKind === 'service-account' || isMergedKinds + ? getServiceConfigByServiceId(serviceId) + : null, + [credentialKind, isMergedKinds, serviceId] ) const selectedCredential = useMemo( @@ -198,12 +204,8 @@ export function CredentialSelector({ ) const handleAddCredential = useCallback(() => { - if (credentialKind === 'custom-bot') { - setShowSlackBotModal(true) - return - } if (credentialKind === 'service-account') { - setShowServiceAccountModal(true) + setShowSetupModal(true) return } setShowConnectModal(true) @@ -239,6 +241,7 @@ export function CredentialSelector({ const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) } + if (isMergedKinds) return [] const options = credentials.map((cred) => ({ label: cred.name, @@ -248,17 +251,14 @@ export function CredentialSelector({ options.push({ label: - credentialKind === 'custom-bot' - ? credentials.length > 0 - ? 'Connect another custom bot' - : 'Set up a custom bot' - : credentialKind === 'service-account' - ? credentials.length > 0 + credentialKind === 'service-account' + ? (subBlock.credentialLabels?.serviceAccountConnect ?? + (credentials.length > 0 ? `Add another ${getProviderName(provider)} key` - : `Add ${getProviderName(provider)} key` - : credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, + : `Add ${getProviderName(provider)} key`)) + : credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, value: '__connect_account__', iconElement: , }) @@ -266,9 +266,54 @@ export function CredentialSelector({ return options }, [ isAllCredentials, + isMergedKinds, allWorkspaceCredentials, credentials, credentialKind, + subBlock.credentialLabels, + provider, + getProviderIcon, + getProviderName, + ]) + + const comboboxGroups = useMemo(() => { + if (!isMergedKinds) return undefined + + const labels = subBlock.credentialLabels + const toOption = (cred: (typeof credentials)[number]) => ({ + label: cred.name, + value: cred.id, + iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider), + }) + + return [ + { + section: labels?.oauthGroup ?? `${getProviderName(provider)} accounts`, + items: [ + ...credentials.filter((c) => c.type !== 'service_account').map(toOption), + { + label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + iconElement: , + }, + ], + }, + { + section: labels?.serviceAccountGroup ?? 'Service accounts', + items: [ + ...credentials.filter((c) => c.type === 'service_account').map(toOption), + { + label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`, + value: '__connect_service_account__', + iconElement: , + }, + ], + }, + ] + }, [ + isMergedKinds, + subBlock.credentialLabels, + credentials, provider, getProviderIcon, getProviderName, @@ -320,9 +365,17 @@ export function CredentialSelector({ const handleComboboxChange = useCallback( (value: string) => { if (value === '__connect_account__') { + if (isMergedKinds) { + setShowConnectModal(true) + return + } handleAddCredential() return } + if (value === '__connect_service_account__') { + setShowSetupModal(true) + return + } const matchedCred = ( isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials @@ -335,13 +388,21 @@ export function CredentialSelector({ setIsEditing(true) setEditingValue(value) }, - [isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect] + [ + isAllCredentials, + isMergedKinds, + allWorkspaceCredentials, + credentials, + handleAddCredential, + handleSelect, + ] ) return (
c.type !== 'service_account').length, workspaceId, requestedAt: Date.now(), }) @@ -417,22 +480,10 @@ export function CredentialSelector({ /> )} - {showSlackBotModal && ( - { - setStoreValue(newCredentialId) - refetchCredentials() - }} - /> - )} - - {showServiceAccountModal && serviceAccountService?.serviceAccountProviderId && ( + {showSetupModal && serviceAccountService?.serviceAccountProviderId && ( tool.toolId === toolId) @@ -799,12 +802,12 @@ export const ToolInput = memo(function ToolInput({ const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : [] const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined - const toolId = getToolIdForOperation(toolBlock.type, defaultOperation) + const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock) if (!toolId) return if (isToolAlreadySelected(toolId, toolBlock.type)) return - const toolParams = getToolParametersConfig(toolId, toolBlock.type) + const toolParams = getToolParametersConfig(toolId, toolBlock.type, undefined, toolBlock) if (!toolParams) return const initialParams: Record = {} @@ -1002,7 +1005,7 @@ export const ToolInput = memo(function ToolInput({ const tool = selectedTools[toolIndex] - const newToolId = getToolIdForOperation(tool.type, operation) + const newToolId = getToolIdForOperation(tool.type, operation, getBlock(tool.type)) if (!newToolId) { return @@ -1589,7 +1592,7 @@ export const ToolInput = memo(function ToolInput({ groups.push({ section: 'Built-in Tools', items: builtInTools.map((block) => { - const toolId = getToolIdForOperation(block.type, undefined) + const toolId = getToolIdForOperation(block.type, undefined, block) const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false return { label: block.name, @@ -1606,7 +1609,7 @@ export const ToolInput = memo(function ToolInput({ groups.push({ section: 'Integrations', items: integrations.map((block) => { - const toolId = getToolIdForOperation(block.type, undefined) + const toolId = getToolIdForOperation(block.type, undefined, block) const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false return { label: block.name, @@ -1705,15 +1708,22 @@ export const ToolInput = memo(function ToolInput({ const currentToolId = !isCustomTool && !isMcpTool - ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || '' + ? getToolIdForOperation(tool.type, tool.operation, toolBlock ?? undefined) || + tool.toolId || + '' : tool.toolId || '' const toolParams = !isCustomTool && !isMcpTool && currentToolId - ? getToolParametersConfig(currentToolId, tool.type, { - operation: tool.operation, - ...tool.params, - }) + ? getToolParametersConfig( + currentToolId, + tool.type, + { + operation: tool.operation, + ...tool.params, + }, + toolBlock ?? undefined + ) : null const toolScopedOverrides = scopeCanonicalModesForTool( @@ -1731,7 +1741,8 @@ export const ToolInput = memo(function ToolInput({ operation: tool.operation, ...tool.params, }, - toolScopedOverrides + toolScopedOverrides, + toolBlock ?? undefined ) : null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index e8752f0c4aa..a860fcfcc33 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -56,11 +56,13 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management' const SLACK_OVERRIDES: SelectorOverrides = { transformContext: (context, deps) => { + // v1 gates on authMethod (raw bot token vs OAuth); v2 has one merged + // credential field for actions and customBotCredential for triggers. const authMethod = deps.authMethod as string const oauthCredential = authMethod === 'bot_token' - ? String(deps.customBotCredential ?? deps.botToken ?? '') - : String(deps.credential ?? deps.customBotCredential ?? deps.triggerCredentials ?? '') + ? String(deps.botToken ?? '') + : String(deps.credential ?? deps.customBotCredential ?? '') return { ...context, oauthCredential } }, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8d9a4fb85e0..4794bd82128 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -66,7 +66,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useReactiveConditions } from '@/hooks/use-reactive-conditions' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' -import { isModelDeprecated } from '@/providers/models' +import { getModelSunsetStatus } from '@/providers/models' import { useVariablesStore } from '@/stores/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -132,12 +132,23 @@ function getBlockSunset( } } - if (typeof model === 'string' && isModelDeprecated(model)) { - return { - status: 'legacy', - kind: 'model', - tooltip: `${model} is deprecated. Click to upgrade`, - prompt: `The "${name}" block uses the deprecated model "${model}". Switch it to the latest equivalent model.`, + if (typeof model === 'string') { + const modelStatus = getModelSunsetStatus(model) + if (modelStatus === 'deprecated') { + return { + status: 'deprecated', + kind: 'model', + tooltip: `${model} is no longer available. Click to switch models`, + prompt: `The "${name}" block uses "${model}", which the provider has retired — calls to it now fail. Switch it to the latest equivalent model.`, + } + } + if (modelStatus === 'legacy') { + return { + status: 'legacy', + kind: 'model', + tooltip: `${model} is a legacy model. Click to upgrade`, + prompt: `The "${name}" block uses the legacy model "${model}". Switch it to the latest equivalent model.`, + } } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index c28f99bbc8b..25dfd5d309e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -146,7 +146,7 @@ const SubBlockRow = memo(function SubBlockRow({ const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) return ( -
+
- - - + {label} ) }, @@ -92,8 +42,7 @@ export const MemoizedCommandItem = memo( prev.icon === next.icon && prev.bgColor === next.bgColor && prev.showColoredIcon === next.showColoredIcon && - prev.label === next.label && - prev.query === next.query + prev.label === next.label ) export const MemoizedActionItem = memo( @@ -103,21 +52,17 @@ export const MemoizedActionItem = memo( icon: Icon, name, shortcut, - query, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - query?: string }) { return ( - - - + {name} {shortcut && ( {shortcut} @@ -130,8 +75,7 @@ export const MemoizedActionItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut && - prev.query === next.query + prev.shortcut === next.shortcut ) export const MemoizedWorkflowItem = memo( @@ -141,14 +85,12 @@ export const MemoizedWorkflowItem = memo( name, folderPath, isCurrent, - query, }: { value: string onSelect: () => void name: string folderPath?: string[] isCurrent?: boolean - query?: string }) { return ( @@ -156,9 +98,7 @@ export const MemoizedWorkflowItem = memo(
- - - + {name} {isCurrent && (current)} {folderPath && folderPath.length > 0 && ( @@ -181,7 +121,6 @@ export const MemoizedWorkflowItem = memo( prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent && - prev.query === next.query && (prev.folderPath === next.folderPath || (prev.folderPath?.length === next.folderPath?.length && (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) @@ -193,13 +132,11 @@ export const MemoizedFileItem = memo( onSelect, name, folderPath, - query, }: { value: string onSelect: () => void name: string folderPath?: string[] - query?: string }) { return ( @@ -207,9 +144,7 @@ export const MemoizedFileItem = memo(
- - - + {name} {folderPath && folderPath.length > 0 && ( @@ -230,7 +165,6 @@ export const MemoizedFileItem = memo( (prev, next) => prev.value === next.value && prev.name === next.name && - prev.query === next.query && (prev.folderPath === next.folderPath || (prev.folderPath?.length === next.folderPath?.length && (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) @@ -241,22 +175,18 @@ export const MemoizedTaskItem = memo( value, onSelect, name, - query, }: { value: string onSelect: () => void name: string - query?: string }) { return ( - - - + {name} ) }, - (prev, next) => prev.value === next.value && prev.name === next.name && prev.query === next.query + (prev, next) => prev.value === next.value && prev.name === next.name ) export const MemoizedWorkspaceItem = memo( @@ -265,30 +195,23 @@ export const MemoizedWorkspaceItem = memo( onSelect, name, isCurrent, - query, }: { value: string onSelect: () => void name: string isCurrent?: boolean - query?: string }) { return ( - - - + {name} {isCurrent && (current)} ) }, (prev, next) => - prev.value === next.value && - prev.name === next.name && - prev.isCurrent === next.isCurrent && - prev.query === next.query + prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent ) export const MemoizedPageItem = memo( @@ -298,21 +221,17 @@ export const MemoizedPageItem = memo( icon: Icon, name, shortcut, - query, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - query?: string }) { return ( - - - + {name} {shortcut && ( {shortcut} @@ -325,8 +244,7 @@ export const MemoizedPageItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut && - prev.query === next.query + prev.shortcut === next.shortcut ) export const MemoizedIconItem = memo( @@ -335,26 +253,18 @@ export const MemoizedIconItem = memo( onSelect, name, icon: Icon, - query, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> - query?: string }) { return ( - - - + {name} ) }, - (prev, next) => - prev.value === next.value && - prev.name === next.name && - prev.icon === next.icon && - prev.query === next.query + (prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/index.ts index 49a29bec4d2..005b06db40f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/index.ts @@ -1,5 +1,4 @@ export { - HighlightedText, MemoizedActionItem, MemoizedCommandItem, MemoizedFileItem, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index 4a6eaed1524..f8e071be9d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -33,11 +33,9 @@ import type { export const ActionsGroup = memo(function ActionsGroup({ items, onSelect, - query, }: { items: ActionItem[] onSelect: (action: ActionItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -50,7 +48,6 @@ export const ActionsGroup = memo(function ActionsGroup({ icon={action.icon} name={action.name} shortcut={action.shortcut} - query={query} /> ))} @@ -60,11 +57,9 @@ export const ActionsGroup = memo(function ActionsGroup({ export const BlocksGroup = memo(function BlocksGroup({ items, onSelect, - query, }: { items: SearchBlockItem[] onSelect: (block: SearchBlockItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -78,7 +73,6 @@ export const BlocksGroup = memo(function BlocksGroup({ bgColor={block.bgColor} showColoredIcon label={block.name} - query={query} /> ))} @@ -88,11 +82,9 @@ export const BlocksGroup = memo(function BlocksGroup({ export const ToolsGroup = memo(function ToolsGroup({ items, onSelect, - query, }: { items: SearchBlockItem[] onSelect: (tool: SearchBlockItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -106,7 +98,6 @@ export const ToolsGroup = memo(function ToolsGroup({ bgColor={tool.bgColor} showColoredIcon label={tool.name} - query={query} /> ))} @@ -116,11 +107,9 @@ export const ToolsGroup = memo(function ToolsGroup({ export const TriggersGroup = memo(function TriggersGroup({ items, onSelect, - query, }: { items: SearchBlockItem[] onSelect: (trigger: SearchBlockItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -134,7 +123,6 @@ export const TriggersGroup = memo(function TriggersGroup({ bgColor={trigger.bgColor} showColoredIcon label={trigger.name} - query={query} /> ))} @@ -144,11 +132,9 @@ export const TriggersGroup = memo(function TriggersGroup({ export const ToolOpsGroup = memo(function ToolOpsGroup({ items, onSelect, - query, }: { items: SearchToolOperationItem[] onSelect: (op: SearchToolOperationItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -162,7 +148,6 @@ export const ToolOpsGroup = memo(function ToolOpsGroup({ bgColor={op.bgColor} showColoredIcon label={op.name} - query={query} /> ))} @@ -172,11 +157,9 @@ export const ToolOpsGroup = memo(function ToolOpsGroup({ export const DocsGroup = memo(function DocsGroup({ items, onSelect, - query, }: { items: SearchDocItem[] onSelect: (doc: SearchDocItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -190,7 +173,6 @@ export const DocsGroup = memo(function DocsGroup({ bgColor='#6B7280' showColoredIcon label={doc.name} - query={query} /> ))} @@ -200,11 +182,9 @@ export const DocsGroup = memo(function DocsGroup({ export const WorkflowsGroup = memo(function WorkflowsGroup({ items, onSelect, - query, }: { items: WorkflowItem[] onSelect: (workflow: WorkflowItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -217,7 +197,6 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({ name={workflow.name} folderPath={workflow.folderPath} isCurrent={workflow.isCurrent} - query={query} /> ))} @@ -227,11 +206,9 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({ export const ChatsGroup = memo(function ChatsGroup({ items, onSelect, - query, }: { items: TaskItem[] onSelect: (task: TaskItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -242,7 +219,6 @@ export const ChatsGroup = memo(function ChatsGroup({ value={`${task.name} task-${task.id}`} onSelect={() => onSelect(task)} name={task.name} - query={query} /> ))} @@ -252,11 +228,9 @@ export const ChatsGroup = memo(function ChatsGroup({ export const WorkspacesGroup = memo(function WorkspacesGroup({ items, onSelect, - query, }: { items: WorkspaceItem[] onSelect: (workspace: WorkspaceItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -268,7 +242,6 @@ export const WorkspacesGroup = memo(function WorkspacesGroup({ onSelect={() => onSelect(workspace)} name={workspace.name} isCurrent={workspace.isCurrent} - query={query} /> ))} @@ -278,11 +251,9 @@ export const WorkspacesGroup = memo(function WorkspacesGroup({ export const PagesGroup = memo(function PagesGroup({ items, onSelect, - query, }: { items: PageItem[] onSelect: (page: PageItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -295,7 +266,6 @@ export const PagesGroup = memo(function PagesGroup({ icon={page.icon} name={page.name} shortcut={page.shortcut} - query={query} /> ))} @@ -311,11 +281,9 @@ export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integra export const FilesGroup = memo(function FilesGroup({ items, onSelect, - query, }: { items: FileItem[] onSelect: (file: FileItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -327,7 +295,6 @@ export const FilesGroup = memo(function FilesGroup({ onSelect={() => onSelect(file)} name={file.name} folderPath={file.folderPath} - query={query} /> ))} @@ -344,11 +311,9 @@ function createColoredIconGroup(heading: string, prefix: string) { return memo(function ColoredIconGroup({ items, onSelect, - query, }: { items: IntegrationSearchItem[] onSelect: (item: IntegrationSearchItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -362,7 +327,6 @@ function createColoredIconGroup(heading: string, prefix: string) { bgColor={item.bgColor} showColoredIcon label={item.name} - query={query} /> ))} @@ -378,11 +342,9 @@ function createIconGroup( return memo(function IconGroup({ items, onSelect, - query, }: { items: TaskItem[] onSelect: (item: TaskItem) => void - query?: string }) { if (items.length === 0) return null return ( @@ -394,7 +356,6 @@ function createIconGroup( onSelect={() => onSelect(item)} name={item.name} icon={icon} - query={query} /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index ec1dc3e630f..6968fbc91f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -712,105 +712,53 @@ export function SearchModal({ {showSection('actions') && ( - + )} {showSection('connectedAccounts') && ( )} {showSection('integrations') && ( )} {showSection('blocks') && ( - + )} {showSection('tools') && ( - + )} {showSection('triggers') && ( - + )} {showSection('chats') && ( - + )} {showSection('workflows') && ( - + )} {showSection('tables') && ( - + )} {showSection('files') && ( - + )} {showSection('knowledgeBases') && ( - + )} {showSection('toolOperations') && ( - + )} {showSection('workspaces') && ( - - )} - {showSection('docs') && ( - + )} + {showSection('docs') && } {showSection('pages') && ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index a9f3fe9dbbe..5b046c31a52 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -91,10 +91,8 @@ export interface CommandItemProps { icon: ComponentType<{ className?: string }> bgColor: string showColoredIcon?: boolean - /** Primary text. Matched characters are highlighted against {@link query}. */ + /** Primary text of the row. */ label: string - /** Active search query, used to bold matched characters. */ - query?: string } export const GROUP_HEADING_CLASSNAME = @@ -181,9 +179,8 @@ function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult { * (`message slack` matches "Slack Send Message") which a strict left-to-right * subsequence would miss. * - * Contiguous substring matches report the indices of the substring itself, so - * highlighting always bolds the run the user actually matched rather than an - * earlier scattered occurrence of the same characters. + * Contiguous substring matches report the indices of the substring itself + * rather than an earlier scattered occurrence of the same characters. */ export function fuzzyMatch(text: string, query: string): FuzzyResult { if (!query) return { matched: true, score: 1, positions: [] } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx index a2b6d234379..89056077039 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx @@ -32,6 +32,9 @@ interface DeleteModalProps { itemName?: string | string[] } +/** Item types that land in Recently Deleted and can be restored from Settings. */ +const RESTORABLE_TYPES = new Set(['workflow', 'folder', 'mixed', 'task']) + /** * Reusable delete confirmation modal for workflow, folder, and workspace items. * Displays a warning message and confirmation buttons. @@ -79,8 +82,6 @@ export function DeleteModal({ title = 'Delete workspace' } - const restorableTypes = new Set(['workflow', 'folder', 'mixed']) - const buildDescriptionSegments = (): ChipConfirmTextSegment[] => { if (itemType === 'workflow') { const warning = { @@ -135,7 +136,7 @@ export function DeleteModal({ if (itemType === 'task') { const warning = { - text: 'This will permanently remove all conversation history.', + text: 'The chat and its conversation history will be archived.', error: true, } if (isMultiple) { @@ -202,7 +203,7 @@ export function DeleteModal({ text={[ ...buildDescriptionSegments(), ' ', - restorableTypes.has(itemType) + RESTORABLE_TYPES.has(itemType) ? 'You can restore it from Recently deleted in Settings.' : 'This action cannot be undone.', ]} diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 9035f519cb9..af6df147547 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -610,17 +610,35 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeployed?.(data) }) - const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => { + /** + * DEGRADED-PATH fallback: replaces the workflow + subblock stores with + * the raw state pushed by the realtime server. The primary join path is + * `syncLocalDraftFromServer`, which fetches MIGRATED state over HTTP — + * the realtime server loads state raw, without the app's block + * migrations (credential remaps, subblock-id renames, canonicalModes + * backfill), because those need the block registry that apps/realtime + * cannot import. Applying raw state is acceptable only as a fallback + * when the HTTP fetch fails: load-time migrations persist their result + * back to the normalized tables (persistMigratedBlocks), so raw state + * converges with migrated state after the first migrated load. If that + * persistence ever fails repeatedly, raw state diverges from the + * migrated deployed snapshot and change detection reports phantom + * diffs that survive redeploys (see the exact text-precision + * updated_at guard in persistMigratedBlocks). + */ + const applyRawJoinStateFallback = async (workflowId: string, workflowState: any) => { const [ { useOperationQueueStore }, { useWorkflowRegistry }, { useWorkflowStore }, { useSubBlockStore }, + { useWorkflowDiffStore }, ] = await Promise.all([ import('@/stores/operation-queue/store'), import('@/stores/workflows/registry/store'), import('@/stores/workflows/workflow/store'), import('@/stores/workflows/subblock/store'), + import('@/stores/workflow-diff/store'), ]) const { activeWorkflowId } = useWorkflowRegistry.getState() @@ -637,6 +655,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) { return false } + if (useWorkflowDiffStore.getState().hasActiveDiff) { + logger.info('Skipping rehydration - an active diff is in progress') + return false + } + const subblockValues: Record> = {} Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => { const blockState = block as any @@ -734,6 +757,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } }) + /** + * Join-time state sync. The realtime server can only load RAW workflow + * state (no block migrations — see applyRawJoinStateFallback), so the raw + * payload is used purely as a trigger and fallback: the stores are + * synced from the app's MIGRATED HTTP state via + * syncLocalDraftFromServer, which dedupes against an in-flight registry + * hydration on the shared query key and refuses to clobber pending + * local operations, active diffs, or newer remote updates. Only when + * that fetch itself fails does the raw payload get applied, so a + * reconnect on a flaky network still recovers to near-current state. + */ socketInstance.on('workflow-state', async (workflowData) => { logger.info('Received workflow state from server') @@ -751,10 +785,40 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } if (workflowData?.state) { + const { canApplyDraftSnapshot, captureDraftVersions, syncLocalDraftFromServer } = + await import('@/stores/workflows/sync-local-draft') + const versionsAtJoin = captureDraftVersions(workflowData.id) + try { - await rehydrateWorkflowStores(workflowData.id, workflowData.state) + const synced = await syncLocalDraftFromServer(workflowData.id) + if (!synced) { + logger.info('Join-state sync skipped; keeping local state', { + workflowId: workflowData.id, + }) + } } catch (error) { - logger.error('Error rehydrating workflow state:', error) + logger.warn('Join-state sync failed; falling back to raw socket state', { error }) + + /** + * The raw payload was captured at join time. Anything applied to + * the stores while the HTTP sync was failing — local edits, + * remote broadcasts, or an external full reload + * (workflow-updated / revert) — is newer than the payload, so + * applying it would regress those changes. The shared snapshot + * guard covers all of those sources. + */ + if (!canApplyDraftSnapshot(workflowData.id, versionsAtJoin)) { + logger.info('Skipping raw join-state fallback; stores changed since join', { + workflowId: workflowData.id, + }) + return + } + + try { + await applyRawJoinStateFallback(workflowData.id, workflowData.state) + } catch (rehydrateError) { + logger.error('Error rehydrating workflow state:', rehydrateError) + } } } }) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 4c5a3516825..75e9c42ae20 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -26,7 +26,6 @@ import { chunkArray, chunkedBatchDelete, DEFAULT_DELETE_CHUNK_SIZE, - deleteRowsById, selectRowsByIdChunks, } from '@/lib/cleanup/batch-delete' import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup' @@ -530,10 +529,11 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}` ) - // Select workflows + files once. These sets drive BOTH external cleanup - // (chats + S3) AND the DB deletes below — selecting twice could return - // different subsets above the LIMIT cap and orphan or prematurely purge data. - const [doomedWorkflows, fileScope] = await Promise.all([ + // Select workflows + files + soft-deleted chats once. These sets drive BOTH + // external cleanup (chats + S3) AND the DB deletes below — selecting twice + // could return different subsets above the LIMIT cap and orphan or + // prematurely purge data. + const [doomedWorkflows, fileScope, expiredSoftDeletedChats] = await Promise.all([ selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => db .select({ id: workflow.id }) @@ -548,38 +548,91 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise .limit(chunkLimit) ), selectExpiredWorkspaceFiles(workspaceIds, retentionDate), + selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => + db + .select({ id: copilotChats.id }) + .from(copilotChats) + .where( + and( + inArray(copilotChats.workspaceId, chunkIds), + isNotNull(copilotChats.deletedAt), + lt(copilotChats.deletedAt, retentionDate) + ) + ) + .limit(chunkLimit) + ), ]) const doomedWorkflowIds = doomedWorkflows.map((w) => w.id) + const softDeletedChatIds = expiredSoftDeletedChats.map((c) => c.id) let chatCleanup: { execute: () => Promise } | null = null + const doomedChatIds = new Set(softDeletedChatIds) if (doomedWorkflowIds.length > 0) { - const doomedChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) => + const workflowChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) => db .select({ id: copilotChats.id }) .from(copilotChats) .where(inArray(copilotChats.workflowId, chunkIds)) .limit(chunkLimit) ) - - const doomedChatIds = doomedChats.map((c) => c.id) - if (doomedChatIds.length > 0) { - chatCleanup = await prepareChatCleanup(doomedChatIds, label) + for (const chat of workflowChats) { + doomedChatIds.add(chat.id) } } + if (doomedChatIds.size > 0) { + chatCleanup = await prepareChatCleanup([...doomedChatIds], label) + } const fileCleanup = await cleanupWorkspaceFileStorage(fileScope) let totalDeleted = 0 // Delete the workflow + file rows using the exact IDs we already selected. - const workflowResult = await deleteRowsById( - workflow, - workflow.id, - doomedWorkflowIds, - `${label}/workflow` - ) - totalDeleted += workflowResult.deleted + // Re-check the archive cutoff in the DELETE so a workflow restored between + // selection and this point survives — and with it, its chats: their rows are + // never cascaded, so chatCleanup.execute()'s row-existence re-check also + // spares their backend data and files. + for (const batch of chunkArray(doomedWorkflowIds, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await db + .delete(workflow) + .where( + and( + inArray(workflow.id, batch), + isNotNull(workflow.archivedAt), + lt(workflow.archivedAt, retentionDate) + ) + ) + .returning({ id: workflow.id }) + totalDeleted += deleted.length + } catch (error) { + logger.error(`[${label}/workflow] Archived workflow delete failed`, { error }) + } + } + + // Workflow-scoped chats above are removed by the workflow FK cascade; + // soft-deleted mothership chats have no workflow and need their own delete. + // Re-check the soft-delete cutoff in the DELETE itself so a chat restored + // between selection and this point survives (chatCleanup.execute() below + // also re-checks row existence before purging external data). + for (const batch of chunkArray(softDeletedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await db + .delete(copilotChats) + .where( + and( + inArray(copilotChats.id, batch), + isNotNull(copilotChats.deletedAt), + lt(copilotChats.deletedAt, retentionDate) + ) + ) + .returning({ id: copilotChats.id }) + totalDeleted += deleted.length + } catch (error) { + logger.error(`[${label}/copilotChats] Soft-deleted chat delete failed`, { error }) + } + } const legacyFileResult = await deleteExpiredLegacyWorkspaceFileRows( fileCleanup.legacyRows, diff --git a/apps/sim/background/cleanup-tasks.ts b/apps/sim/background/cleanup-tasks.ts index 341f5132eaa..e987ac41078 100644 --- a/apps/sim/background/cleanup-tasks.ts +++ b/apps/sim/background/cleanup-tasks.ts @@ -2,7 +2,6 @@ import { db } from '@sim/db' import { copilotAsyncToolCalls, copilotChats, - copilotFeedback, copilotRunCheckpoints, copilotRuns, mothershipInboxTask, @@ -13,6 +12,8 @@ import { and, inArray, lt } from 'drizzle-orm' import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher' import { batchDeleteByWorkspaceAndTimestamp, + chunkArray, + DEFAULT_DELETE_CHUNK_SIZE, deleteRowsById, selectRowsByIdChunks, type TableCleanupResult, @@ -101,14 +102,6 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise if (r.deleted > 0) logger.info(`[${r.table}] ${r.deleted} deleted`) } - // Delete feedback — no direct workspaceId, reuse chat IDs collected above - const feedbackResult = await deleteRowsById( - copilotFeedback, - copilotFeedback.chatId, - doomedChatIds, - `${label}/copilotFeedback` - ) - // Delete copilot runs (has workspaceId directly, cascades checkpoints) const runsResult = await batchDeleteByWorkspaceAndTimestamp({ tableDef: copilotRuns, @@ -121,12 +114,24 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise // Delete copilot chats using the exact IDs collected above so the chat // cleanup (S3 + copilot backend) and the DB delete can never disagree. - const chatsResult = await deleteRowsById( - copilotChats, - copilotChats.id, - doomedChatIds, - `${label}/copilotChats` - ) + // Re-check the retention cutoff in the DELETE: a chat restored from Recently + // Deleted mid-run gets a fresh `updatedAt`, so it survives here (and + // chatCleanup.execute() re-checks row existence before purging its data). + // Chat-scoped children (copilot_messages, copilot_feedback) go with the row + // via FK cascade, so they are removed only for chats actually deleted. + const chatsResult = { deleted: 0, failed: 0 } + for (const batch of chunkArray(doomedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await db + .delete(copilotChats) + .where(and(inArray(copilotChats.id, batch), lt(copilotChats.updatedAt, retentionDate))) + .returning({ id: copilotChats.id }) + chatsResult.deleted += deleted.length + } catch (error) { + chatsResult.failed += batch.length + logger.error(`[${label}/copilotChats] Chat retention delete failed`, { error }) + } + } // Delete mothership inbox tasks (has workspaceId directly) const inboxResult = await batchDeleteByWorkspaceAndTimestamp({ @@ -140,7 +145,6 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise const totalDeleted = runChildResults.reduce((s, r) => s + r.deleted, 0) + - feedbackResult.deleted + runsResult.deleted + chatsResult.deleted + inboxResult.deleted diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index b0a69183295..1792db96c52 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -2627,48 +2627,51 @@ export const SlackBlockMeta = { ], } as const satisfies BlockMeta -/** - * Custom Bot picker used by slack_v2 in place of v1's raw bot-token field — a - * canonical basic/advanced pair (dropdown + manual credential-ID paste), - * mirroring the OAuth `credential`/`manualCredential` pair. - */ -const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [ - { - id: 'customBotCredential', - title: 'Slack Bot', - type: 'oauth-input', - canonicalParamId: 'botCredential', - mode: 'basic', - serviceId: 'slack', - credentialKind: 'custom-bot', - requiredScopes: getScopesForService('slack'), - placeholder: 'Select a connected bot', - dependsOn: ['authMethod'], - condition: { field: 'authMethod', value: 'bot_token' }, - required: true, - }, - { - id: 'manualCustomBotCredential', - title: 'Bot Credential ID', - type: 'short-input', - canonicalParamId: 'botCredential', - mode: 'advanced', - placeholder: 'Enter bot credential ID', - dependsOn: ['authMethod'], - condition: { field: 'authMethod', value: 'bot_token' }, - required: true, - }, -] - const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set( getTrigger('slack_webhook').subBlocks.map((sb) => sb.id) ) +/** + * Adapts a v1 subblock for slack_v2's merged credential picker: fields gated on + * the removed `authMethod` dropdown now depend on the single `credential` field. + */ +function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { + const { dependsOn, condition, ...rest } = sb + if (sb.id === 'credential') { + return { + ...rest, + credentialKind: 'any', + placeholder: 'Select Slack account or bot', + credentialLabels: { + oauthGroup: 'Sim app', + oauthConnect: 'Connect the Sim app', + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }, + } + } + if (sb.id === 'manualCredential') { + return { ...rest, placeholder: 'Enter credential ID' } + } + if (dependsOn && !Array.isArray(dependsOn) && dependsOn.all?.includes('authMethod')) { + return { ...sb, dependsOn: ['credential'] } + } + return sb +} + +const { + authMethod: _authMethod, + botToken: _botToken, + botCredential: _botCredential, + ...slackV2Inputs +} = SlackBlock.inputs + /** * slack_v2 — the go-forward Slack action block. Identical operations, tools, and - * outputs to v1 (shared by reference), but the "Custom Bot" auth method selects - * a reusable bot credential set up once, instead of pasting a raw token. Also - * hosts the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook). + * outputs to v1 (shared by reference), but auth is a single credential picker + * listing Sim OAuth accounts and reusable custom bots together — the credential's + * kind is resolved server-side, so no auth-method choice is needed. Also hosts + * the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook). */ export const SlackV2Block: BlockConfig = { ...SlackBlock, @@ -2683,13 +2686,18 @@ export const SlackV2Block: BlockConfig = { ...SlackBlock.subBlocks.flatMap((sb) => { // Drop the legacy paste-secret trigger config (v1 hosts slack_webhook) // and v1's raw bot-token auth field — the trigger set includes an - // id-colliding 'botToken', so the set check covers both. + // id-colliding 'botToken', so the set check covers both. The authMethod + // dropdown is gone: the merged credential picker covers both auth kinds. if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return [] - if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS] - return [sb] + if (sb.id === 'authMethod') return [] + return [adaptSubBlockForV2(sb)] }), ...getTrigger('slack_oauth').subBlocks, ], + inputs: { + ...slackV2Inputs, + oauthCredential: { type: 'string', description: 'Slack credential (OAuth account or bot)' }, + }, triggers: { enabled: true, available: ['slack_oauth'], diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index e8f02973d5e..5ff63f41fce 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -76,6 +76,22 @@ export function isReservedOutputName(name: string): boolean { return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase()) } +/** + * Collect a custom block's per-field param values into the child `inputMapping` + * JSON string: every non-reserved, non-empty param keyed by the source field's + * stable id. Shared by the hidden `inputMapping` sub-block (canvas serialization) + * and the agent-tool transform, so both paths assemble the mapping identically. + */ +export function assembleCustomBlockInputMapping(params: Record): string { + const mapping: Record = {} + for (const [key, val] of Object.entries(params)) { + if (RESERVED_PARAMS.has(key)) continue + if (val === undefined || val === '') continue + mapping[key] = val + } + return JSON.stringify(mapping) +} + /** Map a Start input field type to the editor sub-block type used to collect it. */ function subBlockTypeForField(fieldType: string): SubBlockType { switch (fieldType) { @@ -161,15 +177,7 @@ export function buildCustomBlockConfig( type: 'code', language: 'json', hidden: true, - value: (params) => { - const mapping: Record = {} - for (const [key, val] of Object.entries(params)) { - if (RESERVED_PARAMS.has(key)) continue - if (val === undefined || val === '') continue - mapping[key] = val - } - return JSON.stringify(mapping) - }, + value: (params) => assembleCustomBlockInputMapping(params), }, ...fieldSubBlocks, ], diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 3ed75ecefde..1abe2b8e379 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -367,14 +367,24 @@ export interface SubBlockConfig { serviceId?: string requiredScopes?: string[] /** - * Narrows an `oauth-input` selector to a specific credential kind. `'custom-bot'` - * lists only reusable custom Slack bot credentials (service-account type) and its - * connect row opens the custom-bot setup modal instead of the OAuth flow. - * `'service-account'` is the generic equivalent for a no-OAuth provider: it lists - * only service-account credentials and its connect row opens the descriptor-driven - * token-paste modal (`ConnectServiceAccountModal`). + * Narrows an `oauth-input` selector to a specific credential kind. + * `'service-account'` lists only service-account credentials; its connect row + * opens the provider's setup modal (resolved from the service-account setup + * registry — a bespoke wizard when registered, the generic token-paste modal + * otherwise). `'any'` lists OAuth accounts and service accounts together in a + * grouped dropdown with a connect action for each kind. */ - credentialKind?: 'custom-bot' | 'service-account' + credentialKind?: 'service-account' | 'any' + /** + * Overrides the credential picker's section and connect-row copy. Unset keys + * fall back to generic provider-derived labels. + */ + credentialLabels?: { + oauthGroup?: string + oauthConnect?: string + serviceAccountGroup?: string + serviceAccountConnect?: string + } /** * Opts a trigger-mode `oauth-input` selector into listing service-account * credentials, which are otherwise excluded in trigger mode. Set only when the diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index f740a0026bd..f0c7d11f13a 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -11,6 +11,7 @@ import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/block import { getBaseModelProviders, getHostedModels, + getModelSunsetStatus, getProviderIcon, getProviderModels, orderModelIdsByReleaseDate, @@ -74,10 +75,12 @@ export function getModelOptions() { ]) ) - return allModels.map((model) => { - const icon = getProviderIcon(model) - return { label: model, id: model, ...(icon && { icon }) } - }) + return allModels + .filter((model) => getModelSunsetStatus(model) !== 'deprecated') + .map((model) => { + const icon = getProviderIcon(model) + return { label: model, id: model, ...(icon && { icon }) } + }) } /** diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 2cccdce5a10..ef85acd95b3 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -37,6 +37,16 @@ export function EmailFooter({ fontFamily: typography.fontFamily, } + /** + * Social icons are linked images. `display: block` removes the 2–3px gap + * Outlook adds under inline images, and `border: 0` prevents the blue link + * border older Outlook versions draw around linked images. + */ + const socialIconStyle = { + display: 'block' as const, + border: 0, + } + return (
@@ -87,6 +98,7 @@ export function EmailFooter({ width='20' height='20' alt='LinkedIn' + style={socialIconStyle} /> @@ -97,6 +109,7 @@ export function EmailFooter({ width='20' height='20' alt='GitHub' + style={socialIconStyle} /> @@ -107,6 +120,7 @@ export function EmailFooter({ width='20' height='20' alt='Slack' + style={socialIconStyle} /> diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index f28e9a81008..264fd177cdb 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -54,9 +54,8 @@ export function EmailLayout({
{brand.name}
diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index 40ccab62097..234f6f50a60 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -1,7 +1,6 @@ export { Button, buttonVariants } from './button' export { GeneratedPasswordInput } from './generated-password-input' export { Progress } from './progress' -export { SearchHighlight } from './search-highlight' export { Select, SelectContent, diff --git a/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx b/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx new file mode 100644 index 00000000000..7b636a89723 --- /dev/null +++ b/apps/sim/content/library/best-ai-agents-for-data-extraction-and-rag-in-2026/index.mdx @@ -0,0 +1,150 @@ +--- +slug: best-ai-agents-for-data-extraction-and-rag-in-2026 +title: 'Best AI Agents for Data Extraction and RAG in 2026' +description: Compare the best AI agents for document extraction, SQL queries, spreadsheet analysis, and RAG over internal documents in 2026. See how Sim, n8n, Zapier, Make, and Gumloop handle data workflows. +date: 2026-07-01 +updated: 2026-07-01 +authors: + - andrew +readingTime: 19 +tags: [AI Agents, Data Extraction, RAG, Sim] +ogImage: /library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg +canonical: https://www.sim.ai/library/best-ai-agents-for-data-extraction-and-rag-in-2026 +draft: false +faq: + - q: "Do AI agents need a separate vector database for RAG?" + a: "Not always. Sim includes native Knowledge Bases that handle chunking, embedding, storage, and semantic retrieval, so you skip standing up a separate vector database like Pinecone or Weaviate. You need an external vector store only when a platform lacks native retrieval and forces you to wire one in yourself." + - q: "Can I use AI agents to extract tables from PDFs into a database?" + a: "Yes. Sim parses documents through its Files handling, including OCR for scanned pages, and lands the results in Tables as structured rows you can push to a database. Platforms without native parsing require a third-party OCR step, and those steps often break on inconsistent table layouts." + - q: "What's the difference between an AI agent and a RAG pipeline?" + a: "A RAG pipeline retrieves relevant documents and feeds them to a model to ground its answers. An AI agent wraps that retrieval inside a larger workflow, calling tools, querying data, and taking actions across steps. In Sim, the Knowledge Base supplies the retrieval, and the agent decides when to use it." + - q: "What file types and sizes can Sim process?" + a: "Sim accepts PDF, Word, TXT, Markdown, HTML, Excel, PowerPoint, CSV, JSON, and YAML at up to 100 MB per file, with best performance under 50 MB. Scanned PDFs are supported through Azure or Mistral OCR." + - q: "Do these platforms support self-hosted or on-prem data for compliance?" + a: "n8n and Sim both offer self-hosting, which keeps sensitive documents inside your own infrastructure for regulatory requirements. Sim self-hosts under Apache 2.0 via Docker or Kubernetes, while n8n uses a fair-code license with commercial-use restrictions. Zapier, Make, and Gumloop run as managed cloud services, so your data passes through their systems. Check each vendor's current deployment options before committing to a compliance-sensitive workflow." +--- + +## TL;DR + +Sim leads across all four data and RAG use cases because it treats document parsing, structured tables, and semantic retrieval as native primitives rather than add-ons. Each competitor still wins in a specific scenario. + +- **PDF and document extraction:** Sim's [Files](https://docs.sim.ai/files) parsing into structured output is the default, with OCR for scanned pages and support for 10 file types up to 100 MB each. Choose Gumloop when you want pre-built scraping and enrichment nodes. +- **SQL in plain English:** Sim's [Tables](https://docs.sim.ai/tables) ground natural-language queries against schema. Choose n8n when you already run self-hosted database workflows. +- **Spreadsheet analysis:** Sim's Tables ingest CSV and TSV files with inferred column types, batch-inserting rows 5,000 at a time. Choose Make when you need granular, visible transformation steps. +- **RAG over internal documents:** Sim's [Knowledge Bases](https://docs.sim.ai/knowledgebase) with connector sync across 50+ sources keep retrieval fresh. Choose Zapier when RAG is a light task beside hundreds of app connections. + +## What makes an AI agent good at data extraction and RAG? + +Four mechanics separate an agent that actually handles data work from one that stitches together workarounds. The first is native document parsing, meaning the platform reads a PDF or spreadsheet and returns clean structured output without an external OCR service. The second is semantic retrieval backed by a vector store that stays synced to its source, so answers reflect the current document, not a snapshot from three weeks ago. The third is how the platform holds intermediate data. Structured tables let an agent query and transform rows, while flat file passing forces you to reparse the same blob at every step. The fourth is natural-language querying against a database schema, where the agent grounds a plain-English question in the actual columns instead of guessing. + +Where a platform sits on these four mechanics depends on what it was built to do. Zapier, Make, and n8n started as integration platforms that move data between apps, so they treat documents and vectors as payloads to route rather than objects to reason over. You reach RAG through a bolt-on vector database node and reach extraction through a third-party parsing service, and you assemble the pipeline yourself. + +Agent-native platforms like Sim and Gumloop invert that assumption. Sim ships Knowledge Bases for semantic retrieval with connector sync, Tables for structured extraction pipelines, and Files for document handling, so the retrieval and storage layer lives inside the agent. Sim adds a fifth option the others do not have: [Mothership](https://www.sim.ai/blog/mothership), a natural-language control plane with full context over your workflows, tables, knowledge bases, and files. You describe the pipeline in plain English and Sim builds, tests, and deploys it. + +Judge each tool in the sections that follow against these mechanics, not against its marketing. + +## What is the best AI agent for extracting data from PDFs and documents in 2026? + +Sim extracts data from PDFs and documents with the least assembly because it handles the file, parses it, and returns structured output inside one agent. You upload a document through Files, point an agent at it, and pull specific fields into a Table without wiring an OCR service or a parsing API in between. Sim accepts PDF, Word, TXT, Markdown, HTML, Excel, PowerPoint, CSV, JSON, and YAML at up to 100 MB per file, and extracts text from scanned, image-based pages through Azure or Mistral OCR. The document goes in, structured data comes out, and no glue steps sit between the two. + +The four integration platforms take a longer route because none treats document parsing as a first-class capability. n8n gives you a PDF node for basic text extraction, but anything with tables, scanned pages, or mixed layouts pushes you toward a code step or an external OCR service you configure yourself. Zapier and Make both lean on third-party connectors for real extraction, so a receipt or contract flows through a separate parsing app like Docparser or an AI module you pay for per document. Gumloop comes closest among the four with AI-native nodes that read documents, though you still chain the extraction logic node by node rather than describing the fields you want. + +Each non-native route fails in a predictable way. The extra parsing service adds a step that can break independently of your agent, so a layout change in the source PDF silently produces empty fields downstream. Per-document pricing on OCR add-ons turns a batch of ten thousand invoices into a line item you have to forecast, and Zapier's task-based billing compounds that cost because every parsing call counts. Brittle parsing is the quieter failure. A model that guesses at a two-column layout will return misaligned data that looks correct until someone audits it. + +Choose Gumloop over Sim when your documents are mostly clean and you want its pre-built scraping and enrichment nodes to feed extraction without building retrieval yourself. Choose n8n when you already run self-hosted workflows and prefer writing your own parsing code for full control. For most teams pulling structured fields out of messy real-world documents at volume, Sim removes the parsing service, the per-document meter, and the failure point they introduce. + +## Can AI agents query a SQL database in plain English? + +Yes, AI agents can turn plain-English questions into SQL, but the quality depends on how well the platform feeds your database schema to the model before it writes a query. Sim handles this through its Tables, which give the agent a structured, typed view of your data instead of a raw connection string. Because the agent knows the column names, types, and relationships up front, it grounds "show me last quarter's churned accounts" against real fields rather than guessing at table structure. That grounding is what separates a query that runs from one that hallucinates a column name. + +Mothership extends the same grounding to the build step. It holds context across every table in the workspace, so a request like "create a CRM table, seed it with my existing leads, and schedule a daily sync" produces the table, the rows, and the workflow in one pass. No competitor on this list ships an equivalent. + +n8n exposes native database nodes for Postgres, MySQL, and others, and you can pair them with an AI node that drafts SQL. The catch is that you assemble the schema-passing step yourself, often by querying the information schema and piping it into the prompt. n8n gives you full control, but a non-technical user still needs to understand SQL well enough to debug what the model generates. + +Zapier and Make both connect to databases, yet neither treats natural-language querying as a first-class feature. In Zapier, you typically trigger on a row or run a pre-written query, and the AI steps summarize results rather than compose SQL against a live schema. Make lets you build the query flow visually with granular control over each database call, but a non-technical user hits a wall the moment the logic needs a hand-written WHERE clause or a JOIN the visual builder does not template. + +Gumloop leans on AI-specific nodes and can generate queries as part of a data flow, though it still expects you to wire the database connection and supply schema context for reliable output. It stays closer to plain English than Zapier or Make, but you are still stitching retrieval logic together. + +The practical divide is where the plain-English experience ends. Sim keeps it end to end because Tables carry the schema the agent needs. The integration platforms get you a working query, and they push the schema-grounding and SQL debugging back onto whoever built the flow. + +## Which AI agent platform best analyzes spreadsheet data automatically? + +Sim analyzes spreadsheet data with the least manual wrangling because its Tables feature gives you a structured store that agents read and write directly. Import a CSV or TSV and Sim infers column types from the data and batch-inserts rows 5,000 at a time as typed columns rather than a flat array of strings. From there the agent can filter, aggregate, or summarize without you writing a single formula. The data lands in a shape the agent already understands, so analysis starts on the same step the import finishes. + +The four integration-first platforms treat a spreadsheet as a file or an array, and that choice pushes the real work onto you. Zapier reads Google Sheets row by row through its Sheets connector, which fits a trigger-and-append pattern but chokes on anything that needs the whole dataset in view at once. Make and n8n both parse the file into arrays of objects, and any analysis beyond a simple map means you write JavaScript or chain a dozen aggregation modules by hand. Gumloop passes spreadsheet data into AI nodes as text or arrays, so an agent can reason over it, but the structure disappears and large sheets blow past the context window. + +The break point in every non-native approach is the same. The moment your question moves from "read this cell" to "group these rows and compare the totals," you leave the platform's built-in capability and start scripting. In Make you add a Set Variable module and an iterator. In n8n you drop into a Code node and write the reduce yourself. Both work, but you are now maintaining transformation logic that has nothing to do with the agent's actual job, and every schema change in the source sheet risks breaking it silently. + +Choose Sim when spreadsheet analysis is a recurring part of the workflow rather than a one-off export, because Tables keeps the data queryable across steps instead of forcing a fresh parse each run. Reach for Make or n8n when you want to see and control every transformation, and you accept the scripting that comes with that visibility. + +## What is the best AI agent for RAG over internal documents? + +Sim wins for RAG over internal documents because its Knowledge Bases handle semantic retrieval and connector sync as built-in primitives, not assembled parts. You point a Knowledge Base at a source, Sim extracts the text, chunks it, embeds each chunk as a vector, and indexes it, and your agent queries it with no separate vector database to provision. [Chunking is configurable](https://docs.sim.ai/knowledgebase/chunking-strategies) from 100 to 4,000 tokens with 0 to 500 tokens of overlap, so you tune precision against context without leaving the platform. Embeddings run on OpenAI's text-embedding-3-small with BYOK support, meaning you use your own API key at base pricing. + +[Connector sync](https://docs.sim.ai/knowledgebase/connectors) is the part every other approach makes you build by hand. Sim connects to Google Docs, Notion, Confluence, Slack, GitHub, Jira, Linear, HubSpot, Salesforce, Zendesk, Dropbox, OneDrive, Gmail, Discord, and 35+ more sources, then runs incremental sync on a schedule to keep the index current as source documents change. + +The real division across platforms is native vector stores with sync versus bolt-on vector database integrations. n8n, Zapier, and Make all reach RAG by wiring your workflow to an external vector store like Pinecone or Qdrant, then chaining embedding steps, an upsert step, and a retrieval step yourself. That works, and n8n in particular gives you fine control over each stage. You own the entire pipeline, including the parts that break. + +Ongoing maintenance separates a working RAG demo from a RAG system you trust six months later. Initial setup is a one-time cost, and any of these tools can survive it. Sync freshness is the recurring cost. When a document changes in your source, a bolt-on pipeline re-embeds it only if you built a trigger to detect the change, so stale answers creep in quietly. Sim's connector sync re-indexes changed content on its own, which removes the most common cause of a RAG agent returning outdated information. + +Gumloop sits closer to Sim than the integration platforms do, since its AI-first node library includes retrieval-oriented steps. You still assemble the retrieval infrastructure and manage re-indexing yourself, so you get AI-specific building blocks without the managed sync that keeps a knowledge base fresh. + +Choose an external vector store through n8n or Make when your team already runs Pinecone or Qdrant in production and wants the agent to query the same index other services use. In that case, a shared vector store is worth the manual pipeline. For a team standing up RAG over internal documents from scratch, Sim's Knowledge Bases remove the re-indexing and sync work that otherwise turns into a standing maintenance job. + +## How do Sim, n8n, Zapier, Make, and Gumloop compare across data and RAG use cases? + +The table below maps each platform against the four use cases covered above. Read "native" as built into the platform with no extra tooling, "add-on" as a third-party integration you connect and maintain, "partial" as a built-in capability you still chain together by hand, and "manual" as work you assemble yourself with code or multi-step logic. + +| Use case | Sim | n8n | Zapier | Make | Gumloop | +| --- | --- | --- | --- | --- | --- | +| PDF/document extraction | Native (Files + parsing + OCR) | Add-on (OCR nodes) | Add-on (parser apps) | Add-on (parser modules) | Partial (AI nodes, manual chaining) | +| SQL in plain English | Native (Tables + NL) | Manual (query + code) | Add-on (AI module) | Manual (SQL modules) | Add-on (AI query) | +| Spreadsheet analysis | Native (Tables) | Manual (array handling) | Manual (formatter steps) | Manual (iterator logic) | Add-on (AI nodes) | +| RAG over internal docs | Native (Knowledge Bases + sync) | Add-on (vector DB) | Limited (agent knowledge sources, no standalone vector store) | Add-on (vector DB) | Manual (assemble pipeline) | +| Natural-language build layer | Native (Mothership) | None | None | None | None | +| License | Apache 2.0 (fully open source) | Fair-code (use restrictions) | Proprietary | Proprietary | Proprietary | +| Self-hosting | Yes (Docker or Kubernetes) | Yes | No | No | No | + +Sim carries native support across all four use-case rows because Knowledge Bases, Tables, and Files exist as first-class primitives rather than integrations you wire together, and Mothership adds a build layer none of the others offer. Gumloop comes closest on document and enrichment work through its AI node library, but it chains extraction node by node and leaves you to assemble retrieval infrastructure for RAG. n8n and Make both reach every use case through add-ons or manual construction, which trades setup effort for control. Zapier covers extraction and querying through app integrations, and its agent product accepts knowledge sources, but it has no standalone vector store to query directly, so RAG over a large internal corpus falls outside what it does well. + +Use the table to confirm the verdicts, then read the "choose X when" sections that follow to match a platform to your actual workflow shape. + +## Choose n8n when + +Pick n8n when you want to run the whole thing on your own infrastructure and you already write code inside your workflows. n8n installs on your own servers, so your documents and database connections never leave a network you control. For teams with compliance rules that forbid sending internal files to a hosted platform, that alone settles the decision. Worth noting that Sim self-hosts too, under Apache 2.0 rather than n8n's fair-code license, which carries commercial-use restrictions Apache 2.0 does not. + +The second reason is the Code node. n8n lets you drop JavaScript or Python into any step, which means you can assemble a RAG pipeline exactly the way you want it by wiring a vector database, an embedding call, and a retrieval query together by hand. You give up the native Knowledge Base that Sim provides, but you gain full control over chunking, indexing, and which model touches your data. + +The third reason is momentum. If your team has already built dozens of n8n workflows and your operations run through them, adding light document extraction or a database query to an existing flow costs less than migrating to an agent-native platform. Stay with n8n when self-hosting, custom code, and prior investment matter more than having retrieval built in. See the [full OpenAI AgentKit vs n8n vs Sim comparison](https://www.sim.ai/library/openai-vs-n8n-vs-sim) for a deeper breakdown. + +## Choose Zapier when + +Choose Zapier when your team already automates operations through it and your document and data work is a small part of a much larger app landscape. Zapier connects to more than 6,000 apps, so if your daily job is moving records between a CRM, a help desk, a spreadsheet, and a billing tool, keeping one more task inside Zapier beats bolting on a second platform. + +The tradeoff is real. Zapier's native document parsing and retrieval primitives are thin, so anything involving vector search or heavy PDF extraction pushes you toward its AI actions or a third-party parsing add-on. For a light task like pulling a few fields off an invoice and dropping them into a sheet, that is fine. For semantic search across thousands of internal documents, you will fight the tool. Per-task pricing compounds the issue at volume, since every parsing call and every retrieval step meters separately. + +Stay with Zapier when the breadth of connections carries the workflow and the data extraction is occasional rather than central. If document parsing, database queries, and retrieval become the core of what your agent does, you have outgrown what Zapier handles well. + +## Choose Make when + +Choose Make when you need to see and control every step of a branching workflow, and built-in RAG matters less than that visibility. Make's canvas exposes each operation as a discrete module across 3,000+ integrations, so you can trace exactly how a record moves through filters, routers, and transformations. For workflows with heavy conditional logic, where a document routes differently based on ten field values, Make gives you a level of granular control that agent-native abstractions hide behind a prompt. + +That control fits ops teams processing structured records that follow strict rules rather than open-ended documents. If your extraction job is really a series of if-then transformations against known fields, Make's router and aggregator modules handle it cleanly, and you can debug any single step in isolation. You give up native semantic retrieval, so RAG over unstructured documents means wiring in an external vector store and managing the pipeline yourself. When your data is already structured and your logic is complex, that tradeoff favors Make. + +Reach for Make when the workflow shape is the hard part and retrieval is secondary. Reach for Sim when semantic search over documents sits at the center of what your agent does. + +## Choose Gumloop when + +Choose Gumloop when you want pre-built AI nodes for scraping and enrichment, and you'd rather not assemble retrieval infrastructure yourself. Gumloop ships a node library aimed at AI tasks out of the box, so pulling data off a web page, cleaning it, and enriching it with a model takes a few connected nodes rather than a custom pipeline. For teams whose main job is turning messy web sources into usable records, that packaging removes a lot of the setup Sim expects you to configure. + +Gumloop fits marketing, sales, and research teams running enrichment at volume, where the input is a list of URLs or companies and the output is a structured table. Its scraping and extraction nodes are tuned for that pattern, and you spend your time chaining capabilities instead of standing up parsers. The tradeoff shows up when you need durable semantic retrieval over a document corpus that changes, since Gumloop leans on task-level AI nodes rather than a managed vector store with sync. It is also closed source with no self-hosting path, so regulated data has to leave your network. Pick Gumloop when enrichment is the workflow. Pick Sim when a synced knowledge base is. + +## Choose Sim when + +Choose Sim when document extraction, structured data, and semantic retrieval sit at the center of the agent you're building, not at the edges. If your workflow parses PDFs into rows, queries that data in plain English, and retrieves relevant passages from internal documents, Sim gives you all three as native primitives rather than three separate integrations you wire together and maintain, across 1,000+ integrations and every major model provider. + +Sim's Knowledge Bases handle semantic retrieval with connector sync across 50+ sources, so your RAG index stays current without a manual re-indexing job. Tables store extracted data in a structured form your agent can query directly. Files handle document ingestion at up to 100 MB per file with OCR for scanned pages, no bolt-on parsing service required. Mothership lets you describe the whole pipeline in plain English and have Sim build, test, and deploy it. You skip the plumbing that n8n, Zapier, and Make require to reach the same result. + +Sim is Apache 2.0 licensed and self-hostable through Docker or Kubernetes, and the cloud platform is [SOC 2 compliant](https://www.sim.ai/blog/enterprise), so regulated teams can keep documents inside their own infrastructure without giving up the native primitives. + +Pick Sim when you'd otherwise spend more time assembling a vector database, a parsing service, and a data store than building the actual agent logic. Teams shipping RAG-heavy agents feel this most, since every external dependency adds a failure point and a sync problem to debug. If retrieval and extraction are the product, start at [Sim](https://sim.ai) and add integrations only where its native pieces fall short. diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 7bf146ec4ac..47a258f5f9a 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -79,6 +79,17 @@ const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = ] const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value) +/** Chat-deployment auth modes an admin can allow/disallow. `null` config = all allowed. */ +const CHAT_DEPLOY_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [ + { value: 'public', label: 'Public' }, + { value: 'password', label: 'Password' }, + { value: 'email', label: 'Email' }, + { value: 'sso', label: 'SSO' }, +] +const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTIONS.map( + (o) => o.value +) + interface OrganizationMemberOption { userId: string user: { @@ -503,7 +514,7 @@ function BlockToolRow({ className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm' style={{ background: block.bgColor }} > - {BlockIcon && } + {BlockIcon && }
))} + +
+ + Auth modes chat deployments may use + + +
+