diff --git a/.agents/skills/babysit/SKILL.md b/.agents/skills/babysit/SKILL.md index 4af7dbbd855..8f324d190b3 100644 --- a/.agents/skills/babysit/SKILL.md +++ b/.agents/skills/babysit/SKILL.md @@ -1,14 +1,15 @@ --- name: babysit -description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean +description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, keeps it mergeable against staging, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean --- # Babysit PRs Owns a PR end-to-end through review: ship it, wait for the automatic review round, and if it isn't already clean, drive fix → reply → resolve → re-review cycles until Greptile reports 5/5 -and there are zero open comment threads. Designed to be run under `/loop` (no fixed interval — -let it self-pace on review latency) so it survives across multiple wakeups in the same session. +and there are zero open comment threads, keeping the branch mergeable against staging along the +way. Designed to be run under `/loop` (no fixed interval — let it self-pace on review latency) +so it survives across multiple wakeups in the same session. ## When to use @@ -33,8 +34,9 @@ round. Always check both conditions freshly after every push. ## Loop -1. **Check current state** before doing anything: +1. **Check current state** before doing anything, including whether the PR is still mergeable: ```bash + gh pr view --json mergeable gh pr view --json comments -q '[.comments[] | select(.author.login=="greptile-apps")] | last | .body' gh api graphql -f query=' query { repository(owner: "", name: "") { pullRequest(number: ) { @@ -49,14 +51,18 @@ round. Always check both conditions freshly after every push. stop yet: re-run the same query with `after: ""` and keep paging until `hasNextPage` is `false` before evaluating "clean." A PR with more than 50 threads is rare but stopping on a partial page would silently miss unresolved ones past the cutoff. - If Greptile is 5/5 and every thread across all pages has `isResolved: true`, stop — report the - outcome (see "Reporting" below) and skip the rest of this list. + If `mergeable` is `CONFLICTING`, fix that first (step 2). Otherwise, if Greptile is 5/5 and + every thread across all pages has `isResolved: true`, stop — report the outcome (see + "Reporting" below) and skip the rest of this list. -2. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run +2. **If the PR has a merge conflict**, merge `origin/staging`, resolve the conflicts, run the + usual pre-push checks, push, and go to step 8 to re-trigger review. + +3. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run automatically on PR open — confirm via `gh pr checks ` (look for `Cursor Bugbot` / `Greptile Review`) and wait for that first round before doing anything else. -3. **If a review round has landed and it isn't clean**: for every thread where +4. **If a review round has landed and it isn't clean**: for every thread where `isResolved: false`, triage the finding on its own merits — this is the part that requires judgment, not a mechanical loop: - **Real bug**: fix it in the cleanest way available. Match the codebase's existing @@ -71,7 +77,7 @@ round. Always check both conditions freshly after every push. - **Already fixed by an earlier finding in the same round**: note that and resolve without a duplicate code change. -4. **Reply to every thread individually** before resolving it — never resolve silently: +5. **Reply to every thread individually** before resolving it — never resolve silently: ```bash gh api repos///pulls//comments//replies -f body="" ``` @@ -80,7 +86,7 @@ round. Always check both conditions freshly after every push. gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' ``` -5. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, +6. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, the whole check-and-recover flow (stash WIP if needed, rebase, verify the rebase didn't just cleanly replay stray commits, cherry-pick rebuild if it did or if it conflicted). A babysit loop spanning a long session is exactly the scenario where a branch can drift, and pushing @@ -91,7 +97,7 @@ round. Always check both conditions freshly after every push. gates from `/ship` steps 4 and 5. A review-fix round is still a code change and can trip either gate just as easily as the original commit did. -6. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 5's +7. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 6's sync check rewrote history, which includes a plain `git rebase origin/staging` that completed with no conflicts, not only the cherry-pick rebuild path; both rewrite commits already published to the remote, so a plain `git push` can be rejected either way — then run `/ship` @@ -104,24 +110,24 @@ round. Always check both conditions freshly after every push. `git log` is newest-first, so without it a positional comparison can spuriously fail on any multi-commit branch. These two lists must describe the same commits. A review loop runs many pushes across many - rounds; checking sync only before the push (step 5) and never after is how a bad push or a + rounds; checking sync only before the push (step 6) and never after is how a bad push or a PR whose commit history quietly went stale between rounds goes unnoticed. -7. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR +8. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR comments** — never combine them into one comment, each bot only responds to its own mention: ```bash gh pr comment --body "@greptile" gh pr comment --body "@cursor review" ``` -8. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using +9. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using a fallback delay of ~250–300s (Greptile/Cursor typically take 1–3 minutes) — never busy-poll in a sleep loop. Pass the same `/loop babysit PR ` prompt on each wakeup so the loop resumes correctly. -9. **Stop conditions**: clean state reached (see above), or the same unresolved finding survives - two consecutive rounds with no new information (surface it to the user instead of looping - forever), or the user interrupts. +10. **Stop conditions**: clean state reached (see above), or the same unresolved finding or + merge conflict survives two consecutive rounds with no new information (surface it to the + user instead of looping forever), or the user interrupts. ## Reporting diff --git a/.claude/commands/babysit.md b/.claude/commands/babysit.md index 2c21ad6adc5..0c75a25a962 100644 --- a/.claude/commands/babysit.md +++ b/.claude/commands/babysit.md @@ -1,13 +1,14 @@ --- -description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean +description: Drive a PR to a clean review (Greptile 5/5, zero open threads) — ships if needed, keeps it mergeable against staging, triggers Greptile/Cursor Bugbot, fixes real findings, replies to and resolves every thread, and loops until clean --- # Babysit PRs Owns a PR end-to-end through review: ship it, wait for the automatic review round, and if it isn't already clean, drive fix → reply → resolve → re-review cycles until Greptile reports 5/5 -and there are zero open comment threads. Designed to be run under `/loop` (no fixed interval — -let it self-pace on review latency) so it survives across multiple wakeups in the same session. +and there are zero open comment threads, keeping the branch mergeable against staging along the +way. Designed to be run under `/loop` (no fixed interval — let it self-pace on review latency) +so it survives across multiple wakeups in the same session. ## When to use @@ -32,8 +33,9 @@ round. Always check both conditions freshly after every push. ## Loop -1. **Check current state** before doing anything: +1. **Check current state** before doing anything, including whether the PR is still mergeable: ```bash + gh pr view --json mergeable gh pr view --json comments -q '[.comments[] | select(.author.login=="greptile-apps")] | last | .body' gh api graphql -f query=' query { repository(owner: "", name: "") { pullRequest(number: ) { @@ -48,14 +50,18 @@ round. Always check both conditions freshly after every push. stop yet: re-run the same query with `after: ""` and keep paging until `hasNextPage` is `false` before evaluating "clean." A PR with more than 50 threads is rare but stopping on a partial page would silently miss unresolved ones past the cutoff. - If Greptile is 5/5 and every thread across all pages has `isResolved: true`, stop — report the - outcome (see "Reporting" below) and skip the rest of this list. + If `mergeable` is `CONFLICTING`, fix that first (step 2). Otherwise, if Greptile is 5/5 and + every thread across all pages has `isResolved: true`, stop — report the outcome (see + "Reporting" below) and skip the rest of this list. -2. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run +2. **If the PR has a merge conflict**, merge `origin/staging`, resolve the conflicts, run the + usual pre-push checks, push, and go to step 8 to re-trigger review. + +3. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run automatically on PR open — confirm via `gh pr checks ` (look for `Cursor Bugbot` / `Greptile Review`) and wait for that first round before doing anything else. -3. **If a review round has landed and it isn't clean**: for every thread where +4. **If a review round has landed and it isn't clean**: for every thread where `isResolved: false`, triage the finding on its own merits — this is the part that requires judgment, not a mechanical loop: - **Real bug**: fix it in the cleanest way available. Match the codebase's existing @@ -70,7 +76,7 @@ round. Always check both conditions freshly after every push. - **Already fixed by an earlier finding in the same round**: note that and resolve without a duplicate code change. -4. **Reply to every thread individually** before resolving it — never resolve silently: +5. **Reply to every thread individually** before resolving it — never resolve silently: ```bash gh api repos///pulls//comments//replies -f body="" ``` @@ -79,7 +85,7 @@ round. Always check both conditions freshly after every push. gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' ``` -5. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, +6. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, the whole check-and-recover flow (stash WIP if needed, rebase, verify the rebase didn't just cleanly replay stray commits, cherry-pick rebuild if it did or if it conflicted). A babysit loop spanning a long session is exactly the scenario where a branch can drift, and pushing @@ -90,7 +96,7 @@ round. Always check both conditions freshly after every push. gates from `/ship` steps 4 and 5. A review-fix round is still a code change and can trip either gate just as easily as the original commit did. -6. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 5's +7. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 6's sync check rewrote history, which includes a plain `git rebase origin/staging` that completed with no conflicts, not only the cherry-pick rebuild path; both rewrite commits already published to the remote, so a plain `git push` can be rejected either way — then run `/ship` @@ -103,24 +109,24 @@ round. Always check both conditions freshly after every push. `git log` is newest-first, so without it a positional comparison can spuriously fail on any multi-commit branch. These two lists must describe the same commits. A review loop runs many pushes across many - rounds; checking sync only before the push (step 5) and never after is how a bad push or a + rounds; checking sync only before the push (step 6) and never after is how a bad push or a PR whose commit history quietly went stale between rounds goes unnoticed. -7. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR +8. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR comments** — never combine them into one comment, each bot only responds to its own mention: ```bash gh pr comment --body "@greptile" gh pr comment --body "@cursor review" ``` -8. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using +9. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using a fallback delay of ~250–300s (Greptile/Cursor typically take 1–3 minutes) — never busy-poll in a sleep loop. Pass the same `/loop babysit PR ` prompt on each wakeup so the loop resumes correctly. -9. **Stop conditions**: clean state reached (see above), or the same unresolved finding survives - two consecutive rounds with no new information (surface it to the user instead of looping - forever), or the user interrupts. +10. **Stop conditions**: clean state reached (see above), or the same unresolved finding or + merge conflict survives two consecutive rounds with no new information (surface it to the + user instead of looping forever), or the user interrupts. ## Reporting diff --git a/.cursor/commands/babysit.md b/.cursor/commands/babysit.md index 7386b608ac4..babe3a4acd7 100644 --- a/.cursor/commands/babysit.md +++ b/.cursor/commands/babysit.md @@ -2,8 +2,9 @@ Owns a PR end-to-end through review: ship it, wait for the automatic review round, and if it isn't already clean, drive fix → reply → resolve → re-review cycles until Greptile reports 5/5 -and there are zero open comment threads. Designed to be run under `/loop` (no fixed interval — -let it self-pace on review latency) so it survives across multiple wakeups in the same session. +and there are zero open comment threads, keeping the branch mergeable against staging along the +way. Designed to be run under `/loop` (no fixed interval — let it self-pace on review latency) +so it survives across multiple wakeups in the same session. ## When to use @@ -28,8 +29,9 @@ round. Always check both conditions freshly after every push. ## Loop -1. **Check current state** before doing anything: +1. **Check current state** before doing anything, including whether the PR is still mergeable: ```bash + gh pr view --json mergeable gh pr view --json comments -q '[.comments[] | select(.author.login=="greptile-apps")] | last | .body' gh api graphql -f query=' query { repository(owner: "", name: "") { pullRequest(number: ) { @@ -44,14 +46,18 @@ round. Always check both conditions freshly after every push. stop yet: re-run the same query with `after: ""` and keep paging until `hasNextPage` is `false` before evaluating "clean." A PR with more than 50 threads is rare but stopping on a partial page would silently miss unresolved ones past the cutoff. - If Greptile is 5/5 and every thread across all pages has `isResolved: true`, stop — report the - outcome (see "Reporting" below) and skip the rest of this list. + If `mergeable` is `CONFLICTING`, fix that first (step 2). Otherwise, if Greptile is 5/5 and + every thread across all pages has `isResolved: true`, stop — report the outcome (see + "Reporting" below) and skip the rest of this list. -2. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run +2. **If the PR has a merge conflict**, merge `origin/staging`, resolve the conflicts, run the + usual pre-push checks, push, and go to step 8 to re-trigger review. + +3. **If no review has run yet** (fresh PR, no Greptile/Cursor comments): they usually run automatically on PR open — confirm via `gh pr checks ` (look for `Cursor Bugbot` / `Greptile Review`) and wait for that first round before doing anything else. -3. **If a review round has landed and it isn't clean**: for every thread where +4. **If a review round has landed and it isn't clean**: for every thread where `isResolved: false`, triage the finding on its own merits — this is the part that requires judgment, not a mechanical loop: - **Real bug**: fix it in the cleanest way available. Match the codebase's existing @@ -66,7 +72,7 @@ round. Always check both conditions freshly after every push. - **Already fixed by an earlier finding in the same round**: note that and resolve without a duplicate code change. -4. **Reply to every thread individually** before resolving it — never resolve silently: +5. **Reply to every thread individually** before resolving it — never resolve silently: ```bash gh api repos///pulls//comments//replies -f body="" ``` @@ -75,7 +81,7 @@ round. Always check both conditions freshly after every push. gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' ``` -5. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, +6. **Before pushing, re-run the full sync check from `/ship` step 2** — not just the log command, the whole check-and-recover flow (stash WIP if needed, rebase, verify the rebase didn't just cleanly replay stray commits, cherry-pick rebuild if it did or if it conflicted). A babysit loop spanning a long session is exactly the scenario where a branch can drift, and pushing @@ -86,7 +92,7 @@ round. Always check both conditions freshly after every push. gates from `/ship` steps 4 and 5. A review-fix round is still a code change and can trip either gate just as easily as the original commit did. -6. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 5's +7. **Commit and push** the round's fixes as one commit — `--force-with-lease` whenever step 6's sync check rewrote history, which includes a plain `git rebase origin/staging` that completed with no conflicts, not only the cherry-pick rebuild path; both rewrite commits already published to the remote, so a plain `git push` can be rejected either way — then run `/ship` @@ -99,24 +105,24 @@ round. Always check both conditions freshly after every push. `git log` is newest-first, so without it a positional comparison can spuriously fail on any multi-commit branch. These two lists must describe the same commits. A review loop runs many pushes across many - rounds; checking sync only before the push (step 5) and never after is how a bad push or a + rounds; checking sync only before the push (step 6) and never after is how a bad push or a PR whose commit history quietly went stale between rounds goes unnoticed. -7. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR +8. **Re-trigger review** by posting `@greptile` and `@cursor review` as **two separate PR comments** — never combine them into one comment, each bot only responds to its own mention: ```bash gh pr comment --body "@greptile" gh pr comment --body "@cursor review" ``` -8. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using +9. **Wait for the new round**, then go back to step 1. Pace the wait with `ScheduleWakeup` using a fallback delay of ~250–300s (Greptile/Cursor typically take 1–3 minutes) — never busy-poll in a sleep loop. Pass the same `/loop babysit PR ` prompt on each wakeup so the loop resumes correctly. -9. **Stop conditions**: clean state reached (see above), or the same unresolved finding survives - two consecutive rounds with no new information (surface it to the user instead of looping - forever), or the user interrupts. +10. **Stop conditions**: clean state reached (see above), or the same unresolved finding or + merge conflict survives two consecutive rounds with no new information (surface it to the + user instead of looping forever), or the user interrupts. ## Reporting diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d8348d8c4..ca2901c7871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main, staging, dev] pull_request: branches: [main, staging, dev] + # Docs content and markdown don't affect the app build or images; push + # runs stay unfiltered because they feed the deploy pipeline. + paths-ignore: + - 'apps/docs/content/**' + - '**/*.md' concurrency: group: ci-${{ github.ref }} @@ -24,6 +29,7 @@ jobs: detect-version: name: Detect Version runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') outputs: version: ${{ steps.extract.outputs.version }} @@ -46,9 +52,10 @@ jobs: echo "ℹ️ Not a release commit" fi - # Run database migrations before images are pushed: the ECR push triggers - # CodePipeline, so migrating first guarantees the schema is in place before - # the new app version deploys (replaces the removed ECS migration sidecar) + # Run database migrations before images are promoted: the ECR latest/staging + # tag push triggers CodePipeline, so migrating first guarantees the schema is + # in place before the new app version deploys (replaces the removed ECS + # migration sidecar) migrate: name: Migrate DB needs: [test-build] @@ -75,6 +82,7 @@ jobs: needs: [detect-version, migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 permissions: contents: read id-token: write @@ -138,6 +146,7 @@ jobs: needs: [migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -173,14 +182,18 @@ jobs: fi bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim - # Main/staging: build AMD64 images and push to ECR + GHCR + # Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR. + # Runs in parallel with tests — only immutable sha tags are pushed here, and + # the CodePipeline EventBridge triggers filter on exactly the + # latest/staging/dev ECR tags, so nothing deploys and no mutable tag moves + # until promote-images / create-ghcr-manifests retag after the gate. build-amd64: name: Build AMD64 - needs: [test-build, detect-version, migrate] if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 permissions: contents: read packages: write @@ -238,6 +251,9 @@ jobs: env: 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 || '' }} + # Only sha tags here — the ECR deploy tags (latest/staging) are applied + # by promote-images and the GHCR latest-amd64/version tags by + # create-ghcr-manifests, both after tests and migrations pass. - name: Generate tags id: meta run: | @@ -245,26 +261,10 @@ jobs: ECR_REPO="${{ steps.ecr-repo.outputs.name }}" GHCR_IMAGE="${{ matrix.ghcr_image }}" - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - ECR_TAG="latest" - else - ECR_TAG="staging" - fi - ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}" - - TAGS="${ECR_IMAGE}" + TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}" if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then - GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" - GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" - TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" - - if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then - VERSION="${{ needs.detect-version.outputs.version }}" - GHCR_VERSION="${GHCR_IMAGE}:${VERSION}-amd64" - TAGS="${TAGS},$GHCR_VERSION" - echo "📦 Adding version tag: ${VERSION}-amd64" - fi + TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64" fi echo "tags=${TAGS}" >> $GITHUB_OUTPUT @@ -280,11 +280,90 @@ jobs: provenance: false sbom: false - # Build ARM64 images for GHCR (main branch only, runs in parallel) + # Promote the sha-tagged ECR images to the deploy tags once tests and + # migrations pass. Pushing the ECR latest/staging tag is what triggers + # CodePipeline, so this seconds-long manifest retag is the deploy gate — + # the image builds themselves run in parallel with the tests. A single job + # (not a matrix) so all four sha manifests are verified before any tag + # moves; a missing image can't produce a partial mixed-version deploy. + promote-images: + name: Promote Images + needs: [migrate, build-amd64] + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 + with: + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2 + + # Deploy-tag moves must be monotonic: a re-run of an old run must never + # retag latest/staging back to stale code. A superseded first-attempt + # run still promotes — the ci- concurrency group executes runs + # serially in commit order, so an ancestor of head is a forward deploy. + - name: Guard against stale promotion + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: | + STATUS="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${GITHUB_REF_NAME}" --jq '.status' || echo "unknown")" + if [ "$STATUS" = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; }; then + echo "fresh=true" >> $GITHUB_OUTPUT + else + echo "::warning::Skipping promotion of ${{ github.sha }} (branch compare: ${STATUS}, attempt ${{ github.run_attempt }}). Moving the deploy tags here could deploy stale code; push a revert commit to roll back instead." + echo "fresh=false" >> $GITHUB_OUTPUT + fi + + - name: Promote images to deploy tags + if: steps.guard.outputs.fresh == 'true' + env: + ECR_REPOS: >- + ${{ secrets.ECR_APP }} + ${{ secrets.ECR_MIGRATIONS }} + ${{ secrets.ECR_REALTIME }} + ${{ secrets.ECR_PII }} + run: | + REGISTRY="${{ steps.login-ecr.outputs.registry }}" + + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + ECR_TAG="latest" + else + ECR_TAG="staging" + fi + + # Verify every sha image exists before moving any deploy tag, so a + # missing/expired image aborts the whole promotion up front. + for repo in $ECR_REPOS; do + echo "🔍 Verifying ${repo}:${{ github.sha }}" + docker buildx imagetools inspect "${REGISTRY}/${repo}:${{ github.sha }}" > /dev/null + done + + for repo in $ECR_REPOS; do + echo "🚀 Promoting ${repo}:${{ github.sha }} to ${ECR_TAG}" + docker buildx imagetools create \ + -t "${REGISTRY}/${repo}:${ECR_TAG}" \ + "${REGISTRY}/${repo}:${{ github.sha }}" + done + + # Build ARM64 images for GHCR (main branch only, runs in parallel with + # tests). Pushes only the immutable sha tag — latest-arm64/version-arm64 + # are applied by create-ghcr-manifests after the gate, so a failing run + # never moves a documented tag. build-ghcr-arm64: name: Build ARM64 (GHCR Only) - needs: [detect-version] runs-on: blacksmith-8vcpu-ubuntu-2404-arm + timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read @@ -316,21 +395,6 @@ jobs: - name: Set up Docker Buildx uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 - - name: Generate ARM64 tags - id: meta - run: | - IMAGE="${{ matrix.image }}" - TAGS="${IMAGE}:latest-arm64,${IMAGE}:${{ github.sha }}-arm64" - - # Add version tag if this is a release commit - if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then - VERSION="${{ needs.detect-version.outputs.version }}" - TAGS="${TAGS},${IMAGE}:${VERSION}-arm64" - echo "📦 Adding version tag: ${VERSION}-arm64" - fi - - echo "tags=${TAGS}" >> $GITHUB_OUTPUT - - name: Build and push ARM64 to GHCR uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 with: @@ -338,17 +402,21 @@ jobs: file: ${{ matrix.dockerfile }} platforms: linux/arm64 push: true - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ matrix.image }}:${{ github.sha }}-arm64 provenance: false sbom: false - # Create GHCR multi-arch manifests (only for main, after both builds) + # 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 - needs: [build-amd64, build-ghcr-arm64, detect-version] + timeout-minutes: 10 + needs: [promote-images, build-ghcr-arm64, detect-version] if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: + contents: read packages: write strategy: matrix: @@ -366,36 +434,51 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create and push manifests + # Same monotonic guard as promote-images, applied to the public latest + # tags only — immutable sha and version tags are always published. + - name: Guard against stale latest tags + id: guard + env: + GH_TOKEN: ${{ github.token }} run: | - IMAGE_BASE="${{ matrix.image }}" + STATUS="$(gh api "repos/${{ github.repository }}/compare/${{ github.sha }}...${GITHUB_REF_NAME}" --jq '.status' || echo "unknown")" + if [ "$STATUS" = "identical" ] || { [ "$STATUS" = "ahead" ] && [ "${{ github.run_attempt }}" = "1" ]; }; then + echo "fresh=true" >> $GITHUB_OUTPUT + else + echo "::warning::Publishing immutable tags for ${{ github.sha }} but skipping the latest tags (branch compare: ${STATUS}, attempt ${{ github.run_attempt }})." + echo "fresh=false" >> $GITHUB_OUTPUT + fi - # Create latest manifest - docker manifest create "${IMAGE_BASE}:latest" \ - "${IMAGE_BASE}:latest-amd64" \ - "${IMAGE_BASE}:latest-arm64" - docker manifest push "${IMAGE_BASE}:latest" + - name: Publish tags and manifests + run: | + IMAGE="${{ matrix.image }}" + SHA="${{ github.sha }}" - # Create SHA manifest - docker manifest create "${IMAGE_BASE}:${{ github.sha }}" \ - "${IMAGE_BASE}:${{ github.sha }}-amd64" \ - "${IMAGE_BASE}:${{ github.sha }}-arm64" - docker manifest push "${IMAGE_BASE}:${{ github.sha }}" + # Multi-arch manifest from the immutable per-arch sha tags + docker buildx imagetools create -t "${IMAGE}:${SHA}" \ + "${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64" - # Create version manifest if this is a release commit if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then VERSION="${{ needs.detect-version.outputs.version }}" - echo "📦 Creating version manifest: ${VERSION}" - docker manifest create "${IMAGE_BASE}:${VERSION}" \ - "${IMAGE_BASE}:${VERSION}-amd64" \ - "${IMAGE_BASE}:${VERSION}-arm64" - docker manifest push "${IMAGE_BASE}:${VERSION}" + echo "📦 Publishing version tags: ${VERSION}" + docker buildx imagetools create -t "${IMAGE}:${VERSION}-amd64" "${IMAGE}:${SHA}-amd64" + docker buildx imagetools create -t "${IMAGE}:${VERSION}-arm64" "${IMAGE}:${SHA}-arm64" + docker buildx imagetools create -t "${IMAGE}:${VERSION}" \ + "${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64" + fi + + if [ "${{ steps.guard.outputs.fresh }}" = "true" ]; then + docker buildx imagetools create -t "${IMAGE}:latest-amd64" "${IMAGE}:${SHA}-amd64" + docker buildx imagetools create -t "${IMAGE}:latest-arm64" "${IMAGE}:${SHA}-arm64" + docker buildx imagetools create -t "${IMAGE}:latest" \ + "${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64" fi # Check if docs changed check-docs-changes: name: Check Docs Changes runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 if: github.event_name == 'push' && github.ref == 'refs/heads/main' outputs: docs_changed: ${{ steps.filter.outputs.docs }} @@ -412,10 +495,10 @@ jobs: - 'apps/sim/scripts/process-docs.ts' - 'apps/sim/lib/chunkers/**' - # Process docs embeddings (only when docs change, after ECR images are pushed) + # Process docs embeddings (only when docs change, after images are promoted) process-docs: name: Process Docs - needs: [build-amd64, check-docs-changes] + needs: [promote-images, check-docs-changes] if: needs.check-docs-changes.outputs.docs_changed == 'true' uses: ./.github/workflows/docs-embeddings.yml secrets: inherit @@ -424,6 +507,7 @@ jobs: create-release: name: Create GitHub Release runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 10 needs: [create-ghcr-manifests, detect-version] if: needs.detect-version.outputs.is_release == 'true' permissions: diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 9c22877e37f..2d149210660 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -30,6 +30,7 @@ permissions: jobs: companion: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index d8b68d60184..76cf58c584e 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -11,6 +11,7 @@ jobs: process-docs-embeddings: name: Process Documentation Embeddings runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 if: github.ref == 'refs/heads/main' steps: @@ -25,7 +26,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: latest + node-version: 22 - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml deleted file mode 100644 index 07fc1a999d0..00000000000 --- a/.github/workflows/i18n.yml +++ /dev/null @@ -1,178 +0,0 @@ -name: 'Auto-translate Documentation' - -on: - workflow_dispatch: # Manual trigger only (scheduled runs disabled) - -permissions: - contents: write - pull-requests: write - -jobs: - translate: - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.actor != 'github-actions[bot]' # Prevent infinite loops - - steps: - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - ref: staging - token: ${{ secrets.GH_PAT }} - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.13 - - - name: Cache Bun dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.bun/install/cache - node_modules - **/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Run Lingo.dev translations - env: - LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} - run: | - cd apps/docs - bunx lingo.dev@latest i18n - - - name: Check for translation changes - id: changes - run: | - cd apps/docs - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - if [ -n "$(git status --porcelain content/docs)" ]; then - echo "changes=true" >> $GITHUB_OUTPUT - else - echo "changes=false" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request with translations - if: steps.changes.outputs.changes == 'true' - uses: peter-evans/create-pull-request@4e1beaa7521e8b457b572c090b25bd3db56bf1c5 # v5 - with: - token: ${{ secrets.GH_PAT }} - commit-message: "feat(i18n): update translations" - title: "feat(i18n): update translations" - body: | - ## Summary - Automated weekly translation updates for documentation. - - This PR was automatically created by the scheduled weekly i18n workflow, updating translations for all supported languages using Lingo.dev AI translation engine. - - **Triggered**: Weekly scheduled run - **Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - ## Type of Change - - [ ] Bug fix - - [ ] New feature - - [ ] Breaking change - - [x] Documentation - - [ ] Other: ___________ - - ## Testing - This PR includes automated translations for modified English documentation content: - - 🇪🇸 Spanish (es) translations - - 🇫🇷 French (fr) translations - - 🇨🇳 Chinese (zh) translations - - 🇯🇵 Japanese (ja) translations - - 🇩🇪 German (de) translations - - **What reviewers should focus on:** - - Verify translated content accuracy and context - - Check that all links and references work correctly in translated versions - - Ensure formatting, code blocks, and structure are preserved - - Validate that technical terms are appropriately translated - - ## Checklist - - [x] Code follows project style guidelines (automated translation) - - [x] Self-reviewed my changes (automated process) - - [ ] Tests added/updated and passing - - [x] No new warnings introduced - - [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) - - ## Screenshots/Videos - - - branch: auto-translate/weekly-${{ github.run_id }} - base: staging - labels: | - i18n - - verify-translations: - needs: translate - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: always() # Run even if translation fails - - steps: - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - ref: staging - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.13 - - - name: Cache Bun dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.bun/install/cache - node_modules - **/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: | - cd apps/docs - bun install --frozen-lockfile - - - name: Build documentation to verify translations - env: - DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy - run: | - cd apps/docs - bun run build - - - name: Report translation status - run: | - cd apps/docs - echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY - echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - en_count=$(find content/docs/en -name "*.mdx" | wc -l) - es_count=$(find content/docs/es -name "*.mdx" 2>/dev/null | wc -l || echo 0) - fr_count=$(find content/docs/fr -name "*.mdx" 2>/dev/null | wc -l || echo 0) - zh_count=$(find content/docs/zh -name "*.mdx" 2>/dev/null | wc -l || echo 0) - ja_count=$(find content/docs/ja -name "*.mdx" 2>/dev/null | wc -l || echo 0) - de_count=$(find content/docs/de -name "*.mdx" 2>/dev/null | wc -l || echo 0) - - es_percentage=$((es_count * 100 / en_count)) - fr_percentage=$((fr_count * 100 / en_count)) - zh_percentage=$((zh_count * 100 / en_count)) - ja_percentage=$((ja_count * 100 / en_count)) - de_percentage=$((de_count * 100 / en_count)) - - echo "### Coverage Statistics" >> $GITHUB_STEP_SUMMARY - echo "- **🇬🇧 English**: $en_count files (source)" >> $GITHUB_STEP_SUMMARY - echo "- **🇪🇸 Spanish**: $es_count/$en_count files ($es_percentage%)" >> $GITHUB_STEP_SUMMARY - echo "- **🇫🇷 French**: $fr_count/$en_count files ($fr_percentage%)" >> $GITHUB_STEP_SUMMARY - echo "- **🇨🇳 Chinese**: $zh_count/$en_count files ($zh_percentage%)" >> $GITHUB_STEP_SUMMARY - echo "- **🇯🇵 Japanese**: $ja_count/$en_count files ($ja_percentage%)" >> $GITHUB_STEP_SUMMARY - echo "- **🇩🇪 German**: $de_count/$en_count files ($de_percentage%)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "🔄 **Auto-translation PR**: Check for new pull request with updated translations" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 7965eaf5e6d..593bf854a4e 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -25,6 +25,7 @@ jobs: migrate: name: Apply Database Migrations runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 steps: - name: Checkout code diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 1ca35d80cb2..da24a3959be 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -12,6 +12,7 @@ permissions: jobs: publish-npm: runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml index 9c2abbfe255..1890c10b9f5 100644 --- a/.github/workflows/publish-python-sdk.yml +++ b/.github/workflows/publish-python-sdk.yml @@ -12,6 +12,7 @@ permissions: jobs: publish-pypi: runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 85a3c138b85..f64f3867a74 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -12,6 +12,7 @@ permissions: jobs: publish-npm: runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 20c2239714c..f20cae5f7d6 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -9,8 +9,9 @@ permissions: jobs: test-build: - name: Test and Build + name: Lint and Test runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout code @@ -24,34 +25,30 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: latest + 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 + # internal-PR runs. - name: Mount Bun cache (Sticky Disk) uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 with: - key: ${{ github.repository }}-bun-cache + 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 with: - key: ${{ github.repository }}-node-modules + 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 with: - key: ${{ github.repository }}-turbo-cache + key: ${{ github.repository }}-turbo-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo - - name: Restore Next.js build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: ./apps/sim/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-nextjs- - - name: Install dependencies run: bun install --frozen-lockfile @@ -175,6 +172,68 @@ jobs: fi echo "✅ Schema and migrations are in sync" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 + with: + directory: ./apps/sim/coverage + fail_ci_if_error: false + verbose: true + + # Next.js production build, in parallel with lint + tests. Sticky disks are + # cloned from the last committed snapshot per job and committed last-writer- + # wins, so concurrent mounts are safe. The bun/node_modules disks are shared + # with test-build (identical content from the same lockfile — LWW loss is + # 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. + build: + name: Build App + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + + - name: Mount Bun cache (Sticky Disk) + uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1 + with: + 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 + with: + 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 + with: + key: ${{ github.repository }}-turbo-cache-build-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + path: ./.turbo + + - name: Restore Next.js build cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ./apps/sim/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-nextjs- + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Build application env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' @@ -186,11 +245,4 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 - with: - directory: ./apps/sim/coverage - fail_ci_if_error: false - verbose: true \ No newline at end of file + run: bunx turbo run build --filter=sim \ No newline at end of file diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index d18862c51e0..1ebe7b408bc 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2801,6 +2801,52 @@ export function LinqIcon(props: SVGProps) { ) } +export function ClickUpIcon(props: SVGProps) { + const id = useId() + const bodyGradientId = `clickup_body_${id}` + const arrowGradientId = `clickup_arrow_${id}` + return ( + + + + + + + + + + + + + ) +} + export function LinearIcon(props: React.SVGProps) { return ( ) => ( ) +export const NvidiaIcon = (props: SVGProps) => ( + + NVIDIA + + +) + +export const ZaiIcon = (props: SVGProps) => ( + + Z.ai + + + + + +) + export function MetaIcon(props: SVGProps) { const id = useId() const gradient1Id = `meta_gradient_1_${id}` @@ -8632,3 +8710,24 @@ export function JupyterIcon(props: SVGProps) { ) } + +export function RocketlaneIcon(props: SVGProps) { + return ( + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index a091d2c4827..cdab0cd4c76 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -34,6 +34,7 @@ import { ClayIcon, ClerkIcon, ClickHouseIcon, + ClickUpIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -181,6 +182,7 @@ import { ResendIcon, RevenueCatIcon, RipplingIcon, + RocketlaneIcon, RootlyIcon, S3Icon, SalesforceIcon, @@ -273,6 +275,7 @@ export const blockTypeToIconMap: Record = { clay: ClayIcon, clerk: ClerkIcon, clickhouse: ClickHouseIcon, + clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, @@ -350,6 +353,7 @@ export const blockTypeToIconMap: Record = { google_vault: GoogleVaultIcon, grafana: GrafanaIcon, grain: GrainIcon, + grain_v2: GrainIcon, granola: GranolaIcon, greenhouse: GreenhouseIcon, greptile: GreptileIcon, @@ -441,6 +445,7 @@ export const blockTypeToIconMap: Record = { resend: ResendIcon, revenuecat: RevenueCatIcon, rippling: RipplingIcon, + rocketlane: RocketlaneIcon, rootly: RootlyIcon, s3: S3Icon, salesforce: SalesforceIcon, diff --git a/apps/docs/content/docs/en/integrations/airtable.mdx b/apps/docs/content/docs/en/integrations/airtable.mdx index 63314699c05..ee76053d391 100644 --- a/apps/docs/content/docs/en/integrations/airtable.mdx +++ b/apps/docs/content/docs/en/integrations/airtable.mdx @@ -142,6 +142,7 @@ Write new records to an Airtable table | `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) | | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `records` | json | Yes | Array of records to create, each with a `fields` object | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output @@ -166,6 +167,7 @@ Update an existing record in an Airtable table by ID | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `recordId` | string | Yes | Record ID to update \(starts with "rec", e.g., "recXXXXXXXXXXXXXX"\) | | `fields` | json | Yes | An object containing the field names and their new values | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output @@ -190,6 +192,7 @@ Update multiple existing records in an Airtable table | `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) | | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `records` | json | Yes | Array of records to update, each with an `id` and a `fields` object | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output diff --git a/apps/docs/content/docs/en/integrations/box-service-account.mdx b/apps/docs/content/docs/en/integrations/box-service-account.mdx new file mode 100644 index 00000000000..2e737045d13 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/box-service-account.mdx @@ -0,0 +1,131 @@ +--- +title: Box Service Accounts +description: Set up a Box Platform app with Client Credentials Grant so your workflows can use Box through its Service Account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Box Platform apps with **Server Authentication (with Client Credentials Grant)** let your workflows authenticate to Box as the app's own **Service Account** instead of through a person's OAuth login. You create the app once, a Box admin authorizes it for the enterprise, and Sim mints short-lived access tokens from the app's credentials on demand — no user consent to expire, and access that's controlled entirely by which folders the Service Account is invited into. + +This is the recommended way to use Box in production workflows: nothing depends on a user staying logged in, the app's scopes are explicit, and the Service Account's reach is auditable folder by folder. + +## Prerequisites + +Anyone with Developer Console access can create the app, but a Box **admin or co-admin** must authorize it in the Admin Console before it can mint tokens. Free developer accounts are authorized automatically. + +## Setting Up the Platform App + +### 1. Create the App + + + + Go to the [Box Developer Console](https://app.box.com/developers/console), open **My Apps**, click **Create Platform App**, and choose **Server Authentication (with Client Credentials Grant)** + + {/* TODO(screenshot): Box Developer Console Create Platform App dialog with Server Authentication (with Client Credentials Grant) selected */} + + + On the **Configuration** tab, set the **App Access Level**. **App Access Only** (the default) is sufficient — choose **App + Enterprise Access** only if the Service Account should also reach existing managed users' content via admin APIs + + + Under **Application Scopes**, check **Read all files and folders stored in Box** and **Write all files and folders stored in Box**. If you'll use Sim's Box Sign operations, also check **Manage signature requests** + + {/* TODO(screenshot): Application Scopes section with read, write, and signature scopes checked */} + + + Copy the **Client ID** and **Client secret** from **Configuration** → **OAuth 2.0 Credentials** (revealing the secret may prompt for two-factor verification) + + {/* TODO(screenshot): OAuth 2.0 Credentials panel showing the Client ID and the client secret reveal */} + + + Copy the **Enterprise ID** — a numeric value. In the Developer Console, click your account icon in the top right and choose **Copy Enterprise ID**; a Box admin can also find it in **Admin Console** → **Account & Billing** → **Account Information** + + + +### 2. Authorize the App in the Admin Console + +Token requests fail with `unauthorized_client` ("This app is not authorized by the enterprise admin") until a Box admin authorizes the app: + + + + A Box admin or co-admin opens **Admin Console** → **Apps** → **Platform Apps Manager** (in some tenants this appears as **Platform** → **Platform Apps**) + + + Click **Add App** and enter the app's **Client ID** + + {/* TODO(screenshot): Platform Apps Manager Add App dialog with the Client ID entered */} + + + +Alternatively, the developer can click **Review and Submit** on the app's **Authorization** tab in the Developer Console to send the request to the admin. + + +**Authorization is a snapshot.** If you later change the app's scopes or access level — for example, adding the signature scope — the admin must **re-authorize** the app in the same Platform Apps Manager section before the change takes effect. Until then, token minting keeps succeeding but the new scopes don't apply, which surfaces as persistent `403` errors on tools despite correct-looking configuration. + + +### 3. Give the Service Account Access to Folders + +The Service Account is a brand-new Box user — its email looks like `AutomationUser_AppServiceID_RandomString@boxdevedition.com` and is shown on the app's **General Settings** tab. Its folder tree starts **empty**: a fully valid credential sees zero items and gets `404`s on real files and folders until you grant it access. + + + + In Box, invite the Service Account's `@boxdevedition.com` email as a **collaborator** on each folder your workflows should work with — use the **Editor** role for read/write access + + {/* TODO(screenshot): Box folder collaboration dialog inviting the AutomationUser email as Editor */} + + + Verify by running Sim's Box **List Folder Items** on folder ID `0` — the collaborated folders should appear + + + + +Community reports indicate the Service Account can't be collaborated into a user's root folder itself — invite it into individual folders instead. + + +## Adding the Service Account to Sim + + + + Open **Integrations** from your workspace sidebar + + + Search for "Box" and open it, then click **Add to Sim** and choose **Add service account** + + {/* TODO(screenshot): Box integration page with the Add service account connect option */} + + + In the **Add Box service account** dialog, paste the **Client ID**, **Client secret**, and **Enterprise ID** (numeric), and optionally set a display name and description + + {/* TODO(screenshot): Add Box service account dialog with all three fields filled in */} + + + Click **Add service account**. Sim verifies the credentials by minting a real access token from Box — if it fails, the error tells you whether Box rejected the credentials or couldn't be reached. A rejection usually means bad credentials, an app the admin hasn't authorized yet, or values that don't all belong to the same app and enterprise. + + + +## Using the Service Account in Workflows + +Add a Box block to your workflow. In the credential dropdown, your Box service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Box block in a workflow with the Box service account selected as the credential */} + +The block calls `api.box.com` with a freshly minted access token — the same requests as the OAuth flow, so every Box operation works, subject to the app's scopes and the folders the Service Account can see. + + +Sim's Box block includes Box Sign operations. These need the **Manage signature requests** scope on the app *and* Box Sign enabled on your enterprise's plan — without either, signature operations fail while file and folder operations keep working. + + +## Token Behavior + +Access tokens minted from the app are short-lived (typically one hour) and there is no refresh token — Sim simply mints a new token when one is needed. The stored Client ID, secret, and Enterprise ID stay valid until you rotate the secret in the Developer Console or the admin removes the app's authorization. If you rotate the secret, update the credential in Sim right away — reconnecting asks you to re-enter all three values. + + diff --git a/apps/docs/content/docs/en/integrations/clickup-service-account.mdx b/apps/docs/content/docs/en/integrations/clickup-service-account.mdx new file mode 100644 index 00000000000..4d06900ecb4 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/clickup-service-account.mdx @@ -0,0 +1,81 @@ +--- +title: ClickUp API Tokens +description: Connect ClickUp to Sim with a personal API token — ideally created from a dedicated service user for production workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +ClickUp personal API tokens let your workflows authenticate without an OAuth consent flow. A token gives full parity with the ClickUp API — everything Sim's ClickUp blocks can do over OAuth works with a token. + +Tokens are bound to the user who creates them: every action a workflow takes is attributed to that user, and the token stops working if the user is deactivated or removed from the workspace. For production workflows, create the token from a dedicated service user (e.g. `sim-bot@yourcompany.com`) rather than a personal account. + +## Prerequisites + +A ClickUp account with access to the workspaces your workflows need. Any user can generate a personal API token from their settings. + +## Creating the API Token + + + + Log in as the service user, click your avatar in ClickUp, and open **Settings** + + {/* TODO(screenshot): ClickUp avatar menu with Settings highlighted */} + + + In the sidebar, go to **Apps** (labeled **API Token** in some plans) + + + Click **Generate** to create your personal token + + {/* TODO(screenshot): ClickUp Apps page with the Generate API token button visible */} + + + Copy the token — it starts with `pk_` — and store it somewhere safe. + + + + +The API token carries the creating user's full access to every workspace they belong to. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +## Adding the API Token to Sim + + + + Open your workspace **Settings** and go to the **Integrations** tab + + + Search for "ClickUp Service Account" and click it, then click **Add to Sim** and choose **Add API token** + + {/* TODO(screenshot): Integrations page with "ClickUp Service Account" in the service list */} + + + Paste the API token (`pk_...`) and optionally set a display name and description + + {/* TODO(screenshot): Add ClickUp API token dialog with the token filled in */} + + + Click **Add API token**. Sim verifies the token by fetching the authorized user from ClickUp — if it fails, you'll see a specific error explaining what went wrong. + + + +The token is encrypted before being stored. + +## Using the Service Account in Workflows + +Add a ClickUp block to your workflow. In the credential dropdown, your ClickUp service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): ClickUp block in a workflow with the service account selected as the credential */} + +The block calls the ClickUp API (`api.clickup.com`) with the token. Everything the workflow does — creating tasks, adding comments, uploading attachments — is attributed to the user who created the token. + + diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx new file mode 100644 index 00000000000..5a2221b8556 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/clickup.mdx @@ -0,0 +1,1251 @@ +--- +title: ClickUp +description: Interact with ClickUp +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. Can also trigger workflows on ClickUp events like task, list, folder, space, and goal changes. + + + +## Actions + +### `clickup_create_task` + +Create a new task in a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to create the task in | +| `name` | string | Yes | Name of the task | +| `description` | string | No | Plain text description of the task | +| `markdownContent` | string | No | Markdown description of the task \(overrides description\) | +| `status` | string | No | Status to create the task with \(must exist in the list\) | +| `priority` | number | No | Priority: 1 \(urgent\), 2 \(high\), 3 \(normal\), 4 \(low\) | +| `dueDate` | number | No | Due date as a Unix timestamp in milliseconds | +| `dueDateTime` | boolean | No | Whether the due date includes a time of day | +| `startDate` | number | No | Start date as a Unix timestamp in milliseconds | +| `startDateTime` | boolean | No | Whether the start date includes a time of day | +| `assignees` | array | No | User IDs to assign to the task | +| `tags` | array | No | Tag names to apply to the task | +| `timeEstimate` | number | No | Time estimate in milliseconds | +| `points` | number | No | Sprint points for the task | +| `parent` | string | No | Parent task ID to create this task as a subtask | +| `notifyAll` | boolean | No | When true, creation notifications are sent to everyone, including the task creator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The created task | + +### `clickup_get_task` + +Retrieve a task from ClickUp by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to retrieve | +| `includeSubtasks` | boolean | No | Include subtasks in the response | +| `includeMarkdownDescription` | boolean | No | Return the task description in Markdown format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The requested task | + +### `clickup_update_task` + +Update an existing task in ClickUp + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to update | +| `name` | string | No | New name for the task | +| `description` | string | No | New plain text description \(use a single space to clear\) | +| `markdownContent` | string | No | New Markdown description \(takes precedence over description\) | +| `status` | string | No | New status for the task \(must exist in the list\) | +| `priority` | number | No | Priority: 1 \(urgent\), 2 \(high\), 3 \(normal\), 4 \(low\) | +| `dueDate` | number | No | New due date as a Unix timestamp in milliseconds | +| `dueDateTime` | boolean | No | Whether the due date includes a time of day | +| `startDate` | number | No | New start date as a Unix timestamp in milliseconds | +| `startDateTime` | boolean | No | Whether the start date includes a time of day | +| `timeEstimate` | number | No | New time estimate in milliseconds | +| `points` | number | No | New sprint points value | +| `parent` | string | No | Parent task ID to move this task under \(cannot be cleared\) | +| `archived` | boolean | No | Set to true to archive the task, false to unarchive | +| `assigneesToAdd` | array | No | User IDs to add as assignees | +| `assigneesToRemove` | array | No | User IDs to remove from assignees | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The updated task | + +### `clickup_delete_task` + +Delete a task from ClickUp + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted task | +| `deleted` | boolean | Whether the task was deleted | + +### `clickup_get_tasks` + +List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to fetch tasks from | +| `page` | number | No | Page to fetch \(starts at 0\) | +| `orderBy` | string | No | Order by field: id, created, updated, or due_date | +| `reverse` | boolean | No | Return tasks in reverse order | +| `subtasks` | boolean | No | Include subtasks \(excluded by default\) | +| `includeClosed` | boolean | No | Include closed tasks \(excluded by default\) | +| `includeMarkdownDescription` | boolean | No | Return task descriptions in Markdown format | +| `archived` | boolean | No | Return archived tasks | +| `statuses` | array | No | Filter tasks by status names | +| `assignees` | array | No | Filter tasks by assignee user IDs | +| `tags` | array | No | Filter tasks by tag names | +| `dueDateGt` | number | No | Only tasks due after this Unix timestamp in milliseconds | +| `dueDateLt` | number | No | Only tasks due before this Unix timestamp in milliseconds | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | Tasks in the list | + +### `clickup_search_tasks` + +Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to search tasks in | +| `page` | number | No | Page to fetch \(starts at 0\) | +| `orderBy` | string | No | Order by field: id, created, updated, or due_date | +| `reverse` | boolean | No | Return tasks in reverse order | +| `subtasks` | boolean | No | Include subtasks \(excluded by default\) | +| `includeClosed` | boolean | No | Include closed tasks \(excluded by default\) | +| `includeMarkdownDescription` | boolean | No | Return task descriptions in Markdown format | +| `listIds` | array | No | Filter by list IDs | +| `spaceIds` | array | No | Filter by space IDs | +| `folderIds` | array | No | Filter by folder IDs | +| `statuses` | array | No | Filter tasks by status names | +| `assignees` | array | No | Filter tasks by assignee user IDs | +| `tags` | array | No | Filter tasks by tag names | +| `dueDateGt` | number | No | Only tasks due after this Unix timestamp in milliseconds | +| `dueDateLt` | number | No | Only tasks due before this Unix timestamp in milliseconds | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | Tasks matching the filters | + +### `clickup_create_comment` + +Add a comment to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to comment on | +| `commentText` | string | Yes | Content of the comment | +| `assignee` | number | No | User ID to assign the comment to | +| `notifyAll` | boolean | No | When true, comment notifications are sent to everyone, including the comment creator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the created comment | +| `histId` | string | History ID of the created comment | +| `date` | number | Creation timestamp of the comment \(Unix ms\) | + +### `clickup_get_comments` + +Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to fetch comments from | +| `start` | number | No | Unix timestamp \(ms\) of the reference comment for pagination \(use the date of the last comment from the previous page, together with startId\) | +| `startId` | string | No | ID of the reference comment for pagination \(use the id of the last comment from the previous page, together with start\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comments` | array | Comments on the task, newest first | + +### `clickup_update_comment` + +Update the content, assignee, or resolved state of a ClickUp task comment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | ID of the comment to update | +| `commentText` | string | No | New content for the comment | +| `assignee` | number | No | User ID to assign the comment to | +| `resolved` | boolean | No | Whether the comment is resolved | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated comment | +| `updated` | boolean | Whether the comment was updated | + +### `clickup_delete_comment` + +Delete a comment from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | ID of the comment to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted comment | +| `deleted` | boolean | Whether the comment was deleted | + +### `clickup_upload_attachment` + +Upload a file to a ClickUp task as an attachment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to attach the file to | +| `file` | file | Yes | File to attach to the task | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attachment` | json | The created attachment | +| ↳ `id` | string | Attachment ID | +| ↳ `version` | string | Attachment version | +| ↳ `title` | string | Attachment title | +| ↳ `extension` | string | File extension | +| ↳ `url` | string | URL of the uploaded attachment | +| ↳ `date` | number | Upload timestamp \(Unix ms\) | +| ↳ `thumbnailSmall` | string | Small thumbnail URL | +| ↳ `thumbnailLarge` | string | Large thumbnail URL | +| `files` | file[] | The uploaded attachment file | + +### `clickup_add_tag_to_task` + +Add an existing space tag to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to tag | +| `tagName` | string | Yes | Name of the tag to add \(must exist in the space\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the tagged task | +| `tagName` | string | Name of the tag that was added | + +### `clickup_remove_tag_from_task` + +Remove a tag from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to remove the tag from | +| `tagName` | string | Yes | Name of the tag to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the task | +| `tagName` | string | Name of the tag that was removed | + +### `clickup_get_space_tags` + +List the task tags available in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to list tags from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | array | Tags available in the space | + +### `clickup_get_task_members` + +List the workspace members who have explicit access to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to list members for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members with explicit access to the task | + +### `clickup_get_list_members` + +List the workspace members who have explicit access to a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to list members for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members with explicit access to the list | + +### `clickup_get_custom_fields` + +List the custom fields accessible in a ClickUp list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | ID of the list to fetch custom fields from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fields` | array | Custom fields accessible in the list | +| ↳ `id` | string | Custom field ID | +| ↳ `name` | string | Custom field name | +| ↳ `type` | string | Custom field type \(e.g. text, number, drop_down\) | +| ↳ `typeConfig` | json | Type-specific configuration \(e.g. dropdown options\) | +| ↳ `dateCreated` | string | Creation timestamp \(Unix ms\) | +| ↳ `hideFromGuests` | boolean | Whether the field is hidden from guests | + +### `clickup_get_workspaces` + +List the ClickUp workspaces (teams) available to the connected account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workspaces` | array | Workspaces available to the connected account | +| ↳ `id` | string | Workspace ID | +| ↳ `name` | string | Workspace name | +| ↳ `color` | string | Workspace color | +| ↳ `avatar` | string | Workspace avatar URL | + +### `clickup_get_spaces` + +List the spaces in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to list spaces from | +| `archived` | boolean | No | Return archived spaces | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaces` | array | Spaces in the workspace | +| ↳ `id` | string | Space ID | +| ↳ `name` | string | Space name | +| ↳ `private` | boolean | Whether the space is private | +| ↳ `archived` | boolean | Whether the space is archived | +| ↳ `statuses` | array | Task statuses available in the space | +| ↳ `status` | string | Status name | +| ↳ `color` | string | Status color | +| ↳ `type` | string | Status type | + +### `clickup_get_folders` + +List the folders in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to list folders from | +| `archived` | boolean | No | Return archived folders | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folders` | array | Folders in the space | + +### `clickup_get_lists` + +List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | No | ID of the folder to list lists from \(provide this or spaceId; folderId takes precedence when both are set\) | +| `spaceId` | string | No | ID of the space to list folderless lists from \(provide this or folderId\) | +| `archived` | boolean | No | Return archived lists | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lists` | array | Lists in the folder or space | + +### `clickup_create_folder` + +Create a new folder in a ClickUp space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `spaceId` | string | Yes | ID of the space to create the folder in | +| `name` | string | Yes | Name of the folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folder` | json | The created folder | + +### `clickup_create_list` + +Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `folderId` | string | No | ID of the folder to create the list in \(provide this or spaceId; folderId takes precedence when both are set\) | +| `spaceId` | string | No | ID of the space to create a folderless list in \(provide this or folderId\) | +| `name` | string | Yes | Name of the list | +| `content` | string | No | Plain text description of the list | +| `markdownContent` | string | No | Markdown description of the list \(use instead of content\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | The created list | + +### `clickup_set_custom_field_value` + +Set the value of a custom field on a ClickUp task (the value shape depends on the field type) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to set the custom field on | +| `fieldId` | string | Yes | UUID of the custom field \(find it with the Get Custom Fields or Get Task operations\) | +| `value` | json | Yes | Value to set. The shape depends on the field type: text/number fields take a plain value, label fields take an array of option UUIDs, dropdown fields take an option UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the updated task | +| `fieldId` | string | ID of the custom field that was set | + +### `clickup_remove_custom_field_value` + +Remove the value of a custom field from a ClickUp task (does not delete the field itself) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to remove the custom field value from | +| `fieldId` | string | Yes | UUID of the custom field to clear | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | ID of the updated task | +| `fieldId` | string | ID of the custom field that was cleared | + +### `clickup_create_checklist` + +Add a new checklist to a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | ID of the task to add the checklist to | +| `name` | string | Yes | Name of the checklist | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The created checklist | + +### `clickup_update_checklist` + +Rename or reorder a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to update | +| `name` | string | No | New name for the checklist | +| `position` | number | No | New position of the checklist on the task \(0 places it first\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated checklist | +| `updated` | boolean | Whether the checklist was updated | + +### `clickup_delete_checklist` + +Delete a checklist from a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted checklist | +| `deleted` | boolean | Whether the checklist was deleted | + +### `clickup_create_checklist_item` + +Add an item to a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist to add the item to | +| `name` | string | Yes | Name of the checklist item | +| `assignee` | number | No | User ID to assign the item to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The updated checklist including its items | + +### `clickup_update_checklist_item` + +Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist containing the item | +| `checklistItemId` | string | Yes | UUID of the checklist item to update | +| `name` | string | No | New name for the checklist item | +| `assignee` | number | No | User ID to assign the item to | +| `resolved` | boolean | No | Whether the item is resolved | +| `parent` | string | No | UUID of another checklist item to nest this item under | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | The updated checklist including its items | + +### `clickup_delete_checklist_item` + +Delete an item from a checklist on a ClickUp task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | UUID of the checklist containing the item | +| `checklistItemId` | string | Yes | UUID of the checklist item to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted checklist item | +| `deleted` | boolean | Whether the item was deleted | + +### `clickup_get_time_entries` + +List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to list time entries from | +| `startDate` | number | No | Start of the date range as a Unix timestamp in milliseconds | +| `endDate` | number | No | End of the date range as a Unix timestamp in milliseconds | +| `assignee` | string | No | Filter by user IDs, comma-separated \(requires workspace owner/admin to view others\) | +| `taskId` | string | No | Only entries for this task \(use at most one location filter\) | +| `listId` | string | No | Only entries in this list \(use at most one location filter\) | +| `folderId` | string | No | Only entries in this folder \(use at most one location filter\) | +| `spaceId` | string | No | Only entries in this space \(use at most one location filter\) | +| `includeTaskTags` | boolean | No | Include task tags in the response | +| `includeLocationNames` | boolean | No | Include list, folder, and space names in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntries` | array | Time entries in the date range | + +### `clickup_create_time_entry` + +Create a manual time entry in a ClickUp workspace, optionally linked to a task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to create the entry in | +| `start` | number | Yes | Start of the entry as a Unix timestamp in milliseconds | +| `duration` | number | Yes | Duration of the entry in milliseconds | +| `description` | string | No | Description of the time entry | +| `billable` | boolean | No | Whether the entry is billable | +| `taskId` | string | No | Task ID to associate the entry with | +| `assignee` | number | No | User ID to create the entry for \(workspace owners/admins only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The created time entry | + +### `clickup_update_time_entry` + +Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the entry belongs to | +| `timerId` | string | Yes | ID of the time entry to update | +| `description` | string | No | New description for the entry | +| `start` | number | No | New start \(Unix ms\); when provided, end must also be provided | +| `end` | number | No | New end \(Unix ms\); when provided, start must also be provided | +| `duration` | number | No | New duration in milliseconds | +| `taskId` | string | No | Task ID to associate the entry with | +| `billable` | boolean | No | Whether the entry is billable | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated time entry | +| `updated` | boolean | Whether the entry was updated | + +### `clickup_delete_time_entry` + +Delete a time entry from a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the entry belongs to | +| `timerId` | string | Yes | ID of the time entry to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The deleted time entry | + +### `clickup_start_timer` + +Start a timer for the authenticated user in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to start the timer in | +| `taskId` | string | No | Task ID to associate the timer with | +| `description` | string | No | Description of the time entry | +| `billable` | boolean | No | Whether the entry is billable | +| `tags` | array | No | Time entry tag names to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The started time entry \(duration is negative while running\) | + +### `clickup_stop_timer` + +Stop the authenticated user's currently running timer in a ClickUp workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) the timer is running in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The stopped time entry | + +### `clickup_get_running_timer` + +Get the currently running time entry in a ClickUp workspace (null when no timer is running) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspaceId` | string | Yes | ID of the workspace \(team\) to check | +| `assignee` | number | No | User ID to check instead of the authenticated user \(owners/admins only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | json | The running time entry \(duration is negative while running\); null when no timer is running | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### ClickUp Folder Created + +Trigger workflow when a folder is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Folder Deleted + +Trigger workflow when a folder is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Folder Updated + +Trigger workflow when a folder is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Goal Created + +Trigger workflow when a goal is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Goal Deleted + +Trigger workflow when a goal is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Goal Updated + +Trigger workflow when a goal is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Created + +Trigger workflow when a key result is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Deleted + +Trigger workflow when a key result is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Updated + +Trigger workflow when a key result is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp List Created + +Trigger workflow when a list is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp List Deleted + +Trigger workflow when a list is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp List Updated + +Trigger workflow when a list is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp Space Created + +Trigger workflow when a space is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Space Deleted + +Trigger workflow when a space is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Space Updated + +Trigger workflow when a space is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Task Assignee Updated + +Trigger workflow when the assignees of a task change in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Comment Posted + +Trigger workflow when a comment is posted on a task in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Comment Updated + +Trigger workflow when a task comment is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Created + +Trigger workflow when a task is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Deleted + +Trigger workflow when a task is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Due Date Updated + +Trigger workflow when the due date of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Moved + +Trigger workflow when a task is moved to a different list in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Priority Updated + +Trigger workflow when the priority of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Status Updated + +Trigger workflow when the status of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Tag Updated + +Trigger workflow when the tags of a task change in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Time Estimate Updated + +Trigger workflow when the time estimate of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Time Tracked Updated + +Trigger workflow when the tracked time of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Updated + +Trigger workflow when a task is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Webhook + +Trigger workflow on any ClickUp event (subscribes to all events) + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task \(task events only\) | +| `listId` | string | ID of the affected list \(list events only\) | +| `folderId` | string | ID of the affected folder \(folder events only\) | +| `spaceId` | string | ID of the affected space \(space events only\) | + diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index bc977d46bad..cfeb0c53c8b 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -27,7 +27,7 @@ Using Sim’s GitLab integration, your agents can programmatically interact with ## Usage Instructions -Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations. +Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments, plus project/group membership, invitations, access requests, SAML group links, and instance user administration. Supports all core GitLab DevOps operations. @@ -67,7 +67,7 @@ Get details of a specific GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path \(e.g., "namespace/project"\) | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) \(e.g., "namespace/project"\) | #### Output @@ -84,13 +84,13 @@ List issues in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, all\) | | `labels` | string | No | Comma-separated list of label names | | `assigneeId` | number | No | Filter by assignee user ID | | `milestoneTitle` | string | No | Filter by milestone title | | `search` | string | No | Search issues by title and description | -| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, title, weight\) | +| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, weight\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -111,7 +111,7 @@ Get details of a specific GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue number within the project \(the # shown in GitLab UI\) | #### Output @@ -129,7 +129,7 @@ Create a new issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `title` | string | Yes | Issue title | | `description` | string | No | Issue description \(Markdown supported\) | | `labels` | string | No | Comma-separated list of label names | @@ -153,7 +153,7 @@ Update an existing issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `title` | string | No | New issue title | | `description` | string | No | New issue description \(Markdown supported\) | @@ -179,7 +179,7 @@ Delete an issue from a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | #### Output @@ -197,9 +197,10 @@ Add a comment to a GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -216,7 +217,7 @@ List merge requests in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, locked, merged, all\) | | `labels` | string | No | Comma-separated list of label names | | `sourceBranch` | string | No | Filter by source branch | @@ -242,7 +243,7 @@ Get details of a specific GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -260,7 +261,7 @@ Create a new merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `sourceBranch` | string | Yes | Source branch name | | `targetBranch` | string | Yes | Target branch name | | `title` | string | Yes | Merge request title | @@ -270,7 +271,7 @@ Create a new merge request in a GitLab project | `milestoneId` | number | No | Milestone ID to assign | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft \(applied via the "Draft:" title prefix\) | #### Output @@ -287,7 +288,7 @@ Update an existing merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `title` | string | No | New merge request title | | `description` | string | No | New merge request description | @@ -298,7 +299,7 @@ Update an existing merge request in a GitLab project | `targetBranch` | string | No | New target branch | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft or remove draft status \(applied via the "Draft:" title prefix; requires title to be set\) | #### Output @@ -315,7 +316,7 @@ Merge a merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `mergeCommitMessage` | string | No | Custom merge commit message | | `squashCommitMessage` | string | No | Custom squash commit message | @@ -338,9 +339,10 @@ Add a comment to a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -357,9 +359,9 @@ List pipelines in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | No | Filter by ref \(branch or tag\) | -| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled\) | +| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled, waiting_for_callback\) | | `orderBy` | string | No | Order by field \(id, status, ref, updated_at, user_id\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -381,7 +383,7 @@ Get details of a specific GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -399,9 +401,10 @@ Trigger a new pipeline in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | Yes | Branch or tag to run the pipeline on | | `variables` | array | No | Array of variables for the pipeline \(each with key, value, and optional variable_type\) | +| `inputs` | json | No | Pipeline inputs as a key/value object \(for pipelines with spec:inputs\) | #### Output @@ -418,7 +421,7 @@ Retry a failed GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -436,7 +439,7 @@ Cancel a running GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -454,7 +457,7 @@ List files and directories in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `path` | string | No | Path inside the repository to list | | `ref` | string | No | Branch, tag, or commit SHA to list from | | `recursive` | boolean | No | Whether to list files recursively | @@ -477,7 +480,7 @@ Get the contents of a file from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `ref` | string | Yes | Branch, tag, or commit SHA | @@ -491,7 +494,8 @@ Get the contents of a file from a GitLab project repository | `ref` | string | The branch, tag, or commit SHA | | `blobId` | string | The blob ID | | `lastCommitId` | string | The last commit ID that modified the file | -| `content` | string | The decoded file content | +| `content` | string | The decoded file content, truncated to 1M characters | +| `truncated` | boolean | Whether the content was truncated | ### `gitlab_create_file` @@ -502,10 +506,14 @@ Create a new file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the new file to | | `content` | string | Yes | File content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | #### Output @@ -524,10 +532,14 @@ Update an existing file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the update to | | `content` | string | Yes | New file content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable or disable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | | `lastCommitId` | string | No | Last known commit ID for the file \(optimistic locking\) | @@ -547,7 +559,7 @@ Create a new branch in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the new branch | | `ref` | string | Yes | Source branch/tag/SHA | @@ -569,7 +581,7 @@ Delete a branch from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the branch to delete | #### Output @@ -587,10 +599,12 @@ Compare two branches, tags, or commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `from` | string | Yes | Commit SHA or branch/tag name to compare from | | `to` | string | Yes | Commit SHA or branch/tag name to compare to | | `straight` | boolean | No | Compare directly from..to instead of using the merge base \(defaults to false\) | +| `fromProjectId` | string | No | ID of the project to compare from \(for cross-fork comparisons\) | +| `unidiff` | boolean | No | Return diffs in unified diff format \(GitLab 16.5+\) | #### Output @@ -612,7 +626,7 @@ List branches in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `search` | string | No | Filter branches by name | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -633,7 +647,7 @@ List commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `refName` | string | No | Branch, tag, or revision range to list commits from | | `since` | string | No | Only commits after this ISO 8601 date | | `until` | string | No | Only commits before this ISO 8601 date | @@ -647,7 +661,7 @@ List commits in a GitLab project repository | Parameter | Type | Description | | --------- | ---- | ----------- | | `commits` | array | List of commits | -| `total` | number | Total number of commits | +| `total` | number | Number of commits returned on this page \(GitLab does not report a grand total for commits\) | ### `gitlab_get_merge_request_changes` @@ -658,7 +672,7 @@ Get the file changes (diffs) of a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -667,7 +681,8 @@ Get the file changes (diffs) of a GitLab merge request | --------- | ---- | ----------- | | `mergeRequestIid` | number | The merge request internal ID \(IID\) | | `changes` | array | List of file changes \(diffs\) | -| `changesCount` | number | Number of changed files returned | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether the merge request has more than 100 changed files \(results truncated\) | ### `gitlab_approve_merge_request` @@ -678,7 +693,7 @@ Approve a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `sha` | string | No | HEAD SHA of the merge request to approve | @@ -699,7 +714,7 @@ List jobs for a GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | | `scope` | string | No | Filter jobs by scope \(e.g. created, running, success, failed\) | | `includeRetried` | boolean | No | Whether to include retried jobs | @@ -722,14 +737,15 @@ Get the log (trace) of a GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `log` | string | The job log \(trace\) output | +| `log` | string | The job log \(trace\) output, truncated to 200k characters | +| `truncated` | boolean | Whether the log was truncated | ### `gitlab_play_job` @@ -740,8 +756,9 @@ Trigger (play) a manual GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | +| `jobVariables` | array | No | Variables for the manual job \(array of objects with key and value\) | #### Output @@ -761,7 +778,7 @@ List releases in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `orderBy` | string | No | Order by field \(released_at, created_at\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -783,12 +800,14 @@ Create a new release in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `tagName` | string | Yes | The Git tag for the release | | `name` | string | No | The release name | | `description` | string | No | Release description/notes \(Markdown supported\) | | `ref` | string | No | Commit SHA, branch, or tag to create the tag from if it does not already exist | | `releasedAt` | string | No | ISO 8601 date for an upcoming or historical release | +| `tagMessage` | string | No | Annotation message to use if creating a new annotated tag | +| `assetLinks` | array | No | Release asset links: array of objects with name, url, and optional link_type \(other, runbook, image, package\) | | `milestones` | array | No | Array of milestone titles to associate with the release | #### Output @@ -797,6 +816,940 @@ Create a new release in a GitLab project | --------- | ---- | ----------- | | `release` | object | The created GitLab release | +### `gitlab_list_members` + +List members of a GitLab project or group. Includes members inherited from ancestor groups by default. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `directOnly` | boolean | No | When true, returns only direct members. Defaults to false, which also returns members inherited from ancestor groups. | +| `query` | string | No | Filter members by name, email, or username | +| `userIds` | string | No | Comma-separated user IDs to filter the results to | +| `state` | string | No | Filter inherited-member results by state: 'awaiting' or 'active' \(Premium/Ultimate; only applies when inherited members are included\) | +| `showSeatInfo` | boolean | No | Include seat information for each member | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | List of project or group members | +| `total` | number | Total number of members | + +### `gitlab_add_member` + +Add an existing GitLab user to a project or group at a given access level + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | No | The ID of the user to add. Provide either userId or username. | +| `username` | string | No | The username of the user to add. Provide either userId or username. | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The added member | +| `alreadyMember` | boolean | Whether the user was already a member \(add was a no-op\) | + +### `gitlab_update_member` + +Update a member's access level in a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The ID of the member to update | +| `accessLevel` | number | Yes | New access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration. | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\). Warning: when omitted, GitLab removes any custom role the member currently holds. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The updated member | + +### `gitlab_remove_member` + +Remove a member from a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The ID of the member to remove | +| `skipSubresources` | boolean | No | Skip deleting the member from subgroups and projects below the target \(defaults to false\) | +| `unassignIssuables` | boolean | No | Unassign the member from all issues and merge requests in the target \(defaults to false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the member was removed successfully | + +### `gitlab_invite_member` + +Invite a person to a GitLab project or group by email address + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address to invite \(comma-separated for multiple\) | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | +| `inviteSource` | string | No | Identifier recorded as the source of the invitation \(for attribution\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Invitation status returned by GitLab | +| `message` | object | Per-email result detail, if any | + +### `gitlab_list_invitations` + +List pending email invitations for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `query` | string | No | Filter invitations by invited email | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitations` | array | List of pending invitations | +| `total` | number | Total number of invitations | + +### `gitlab_update_invitation` + +Update a pending invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address of the invitation to update | +| `accessLevel` | number | No | New access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date \(ISO 8601, e.g. 2026-12-31T00:00:00Z; date-only also accepted\). At least one of accessLevel or expiresAt must be provided. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitation` | object | The updated invitation | + +### `gitlab_revoke_invitation` + +Revoke a pending email invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address of the invitation to revoke | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the invitation was revoked successfully | + +### `gitlab_list_access_requests` + +List pending access requests for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequests` | array | List of pending access requests | +| `total` | number | Total number of access requests | + +### `gitlab_approve_access_request` + +Approve a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The user ID of the access requester | +| `accessLevel` | number | No | Access level to grant: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\). Defaults to 30 \(Developer\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequest` | object | The approved access request | + +### `gitlab_deny_access_request` + +Deny (delete) a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The user ID of the access requester | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the access request was denied successfully | + +### `gitlab_list_saml_group_links` + +List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLinks` | array | List of SAML group links | +| `total` | number | Number of SAML group links | + +### `gitlab_search_users` + +Search for GitLab users by name, username, or email. Email matches must be exact; private emails match only with an admin token. Use this to resolve an email to a user ID before adding a member. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `search` | string | Yes | Name, username, or email to search for | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of matching users | +| `total` | number | Total number of matching users | + +### `gitlab_create_user` + +Create a new GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `email` | string | Yes | The user's email address | +| `username` | string | Yes | The user's username | +| `name` | string | Yes | The user's display name | +| `password` | string | No | The user's password. Omit and set resetPassword to email a reset link instead. | +| `resetPassword` | boolean | No | Send the user a password reset link instead of setting a password | +| `forceRandomPassword` | boolean | No | Set a random password without emailing a reset link \(useful for SSO-only accounts\). One of password, resetPassword, or forceRandomPassword is required. | +| `admin` | boolean | No | Whether the new user is an administrator | +| `skipConfirmation` | boolean | No | Skip email confirmation for the new user | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The created user | + +### `gitlab_update_user` + +Modify an existing GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to modify | +| `email` | string | No | The user's new email address \(GitLab only allows changing to one of the user's existing verified secondary emails\) | +| `username` | string | No | The user's new username | +| `name` | string | No | The user's new display name | +| `admin` | boolean | No | Whether the user is an administrator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The updated user | + +### `gitlab_delete_user` + +Delete a GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to delete | +| `hardDelete` | boolean | No | When true, contributions, personal projects, AND groups owned solely by this user are deleted rather than moved to a Ghost User | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was deleted successfully | + +### `gitlab_block_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_unblock_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_deactivate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_activate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_ban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_unban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_approve_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_reject_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_delete_user_identity` + +Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user | +| `provider` | string | Yes | The external identity provider name \(e.g. saml, ldapmain\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the identity was deleted successfully | + +### `gitlab_add_saml_group_link` + +Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level (GitLab Premium/Ultimate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | +| `samlGroupName` | string | Yes | The name of the SAML group as sent by the identity provider | +| `accessLevel` | number | Yes | Access level granted to members of the SAML group: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | +| `provider` | string | No | Unique provider name that must match for this group link to be applied \(GitLab 18.2+\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLink` | object | The created SAML group link | + +### `gitlab_delete_saml_group_link` + +Delete a SAML group link from a GitLab group (GitLab Premium/Ultimate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | +| `samlGroupName` | string | Yes | The name of the SAML group link to delete | +| `provider` | string | No | Provider name of the link to delete. Required when multiple links share the same SAML group name. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the SAML group link was deleted successfully | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/grain.mdx b/apps/docs/content/docs/en/integrations/grain.mdx index f027f2c9575..c54010f70c1 100644 --- a/apps/docs/content/docs/en/integrations/grain.mdx +++ b/apps/docs/content/docs/en/integrations/grain.mdx @@ -6,7 +6,7 @@ description: Access meeting recordings, transcripts, and AI summaries import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -58,6 +58,7 @@ List recordings from Grain with optional filters and pagination | `includeHighlights` | boolean | No | Include highlights/clips in response | | `includeParticipants` | boolean | No | Include participant list in response | | `includeAiSummary` | boolean | No | Include AI-generated summary | +| `includeAiActionItems` | boolean | No | Include AI-detected action items | #### Output @@ -91,6 +92,7 @@ Get details of a single recording by ID | `includeHighlights` | boolean | No | Include highlights/clips | | `includeParticipants` | boolean | No | Include participant list | | `includeAiSummary` | boolean | No | Include AI summary | +| `includeAiActionItems` | boolean | No | Include AI-detected action items | | `includeCalendarEvent` | boolean | No | Include calendar event data | | `includeHubspot` | boolean | No | Include HubSpot associations | @@ -113,6 +115,7 @@ Get details of a single recording by ID | `highlights` | array | Highlights \(if included\) | | `participants` | array | Participants \(if included\) | | `ai_summary` | object | AI summary text \(if included\) | +| `ai_action_items` | array | AI-detected action items with status, text, and assignee \(if included\) | | `calendar_event` | object | Calendar event data \(if included\) | | `hubspot` | object | HubSpot associations \(if included\) | @@ -138,26 +141,6 @@ Get the full transcript of a recording | ↳ `end` | number | End timestamp in ms | | ↳ `text` | string | Transcript text | -### `grain_list_views` - -List available Grain views for webhook subscriptions - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | -| `typeFilter` | string | No | Optional view type filter: recordings, highlights, or stories | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `views` | array | Array of Grain views | -| ↳ `id` | string | View UUID | -| ↳ `name` | string | View name | -| ↳ `type` | string | View type: recordings, highlights, or stories | - ### `grain_list_teams` List all teams in the workspace @@ -197,16 +180,16 @@ List all meeting types in the workspace ### `grain_create_hook` -Create a webhook to receive recording events +Create a webhook for a specific Grain event type (v2 API) #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | -| `hookUrl` | string | Yes | Webhook endpoint URL \(e.g., "https://example.com/webhooks/grain"\) | -| `viewId` | string | Yes | Grain view ID from GET /_/public-api/views | -| `actions` | array | No | Optional list of actions to subscribe to: added, updated, removed | +| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) | +| `hookUrl` | string | Yes | Webhook endpoint URL. Grain performs a reachability test on creation — the endpoint must respond 2xx. | +| `hookType` | string | Yes | Event type the hook subscribes to. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status | +| `include` | json | No | Optional include object controlling payload richness. For recording hooks: \{"participants": true, "highlights": true, "ai_summary": true\}. For highlight hooks: \{"transcript": true, "speakers": true\}. | #### Output @@ -215,19 +198,21 @@ Create a webhook to receive recording events | `id` | string | Hook UUID | | `enabled` | boolean | Whether hook is active | | `hook_url` | string | The webhook URL | -| `view_id` | string | Grain view ID for the webhook | -| `actions` | array | Configured actions for the webhook | +| `hook_type` | string | Event type the hook subscribes to | +| `include` | json | Include object the hook was created with | | `inserted_at` | string | ISO8601 creation timestamp | ### `grain_list_hooks` -List all webhooks for the account +List webhooks for the account (v2 API) #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) | +| `hookType` | string | No | Only return hooks with this event type. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status | +| `state` | string | No | Only return hooks that are "enabled" or "disabled" | #### Output @@ -237,19 +222,19 @@ List all webhooks for the account | ↳ `id` | string | Hook UUID | | ↳ `enabled` | boolean | Whether hook is active | | ↳ `hook_url` | string | Webhook URL | -| ↳ `view_id` | string | Grain view ID | -| ↳ `actions` | array | Configured actions | +| ↳ `hook_type` | string | Event type the hook subscribes to | +| ↳ `include` | object | Include object the hook was created with | | ↳ `inserted_at` | string | Creation timestamp | ### `grain_delete_hook` -Delete a webhook by ID +Delete a webhook by ID (v2 API) #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | +| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) | | `hookId` | string | Yes | The hook UUID to delete \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) | #### Output @@ -266,14 +251,13 @@ A **Trigger** is a block that starts a workflow when an event happens in this se ### Grain All Events -Trigger on all actions (added, updated, removed) in a Grain view +Trigger on every Grain event (recordings, highlights, stories, uploads) #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). | #### Output @@ -286,16 +270,15 @@ Trigger on all actions (added, updated, removed) in a Grain view --- -### Grain Highlight Created +### Grain Highlight Added -Trigger workflow when a new highlight/clip is created in Grain +Trigger when a new highlight/clip is created in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | Required by Grain to create the webhook subscription. | #### Output @@ -317,18 +300,38 @@ Trigger workflow when a new highlight/clip is created in Grain | ↳ `created_datetime` | string | ISO8601 creation timestamp | +--- + +### Grain Highlight Deleted + +Trigger when a highlight/clip is deleted in Grain + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Required to create the webhook in Grain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., recording_added\) | +| `user_id` | string | User UUID who triggered the event | +| `data` | object | Event data object \(recording, highlight, etc.\) | + + --- ### Grain Highlight Updated -Trigger workflow when a highlight/clip is updated in Grain +Trigger when a highlight/clip is updated in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | Required by Grain to create the webhook subscription. | #### Output @@ -352,38 +355,48 @@ Trigger workflow when a highlight/clip is updated in Grain --- -### Grain Item Added +### Grain Recording Added -Trigger when a new item is added to a Grain view (recording, highlight, or story) +Trigger when a new recording is added in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `type` | string | Event type \(e.g., recording_added\) | +| `type` | string | Event type | | `user_id` | string | User UUID who triggered the event | -| `data` | object | Event data object \(recording, highlight, etc.\) | +| `data` | object | data output from the tool | +| ↳ `id` | string | Recording UUID | +| ↳ `title` | string | Recording title | +| ↳ `start_datetime` | string | ISO8601 start timestamp | +| ↳ `end_datetime` | string | ISO8601 end timestamp | +| ↳ `duration_ms` | number | Duration in milliseconds | +| ↳ `media_type` | string | audio, transcript, or video | +| ↳ `source` | string | Recording source \(zoom, meet, local_capture, etc.\) | +| ↳ `url` | string | URL to view in Grain | +| ↳ `thumbnail_url` | string | Thumbnail URL \(nullable\) | +| ↳ `tags` | array | Array of tag strings | +| ↳ `teams` | array | Array of team objects | +| ↳ `meeting_type` | object | Meeting type info with id, name, scope \(nullable\) | --- -### Grain Item Updated +### Grain Recording Deleted -Trigger when an item is updated in a Grain view (recording, highlight, or story) +Trigger when a recording is deleted in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). | #### Output @@ -396,16 +409,15 @@ Trigger when an item is updated in a Grain view (recording, highlight, or story) --- -### Grain Recording Created +### Grain Recording Updated -Trigger workflow when a new recording is added in Grain +Trigger when a recording is updated in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | Required by Grain to create the webhook subscription. | #### Output @@ -430,16 +442,15 @@ Trigger workflow when a new recording is added in Grain --- -### Grain Recording Updated +### Grain Story Added -Trigger workflow when a recording is updated in Grain +Trigger when a new story is created in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | Required by Grain to create the webhook subscription. | #### Output @@ -448,32 +459,44 @@ Trigger workflow when a recording is updated in Grain | `type` | string | Event type | | `user_id` | string | User UUID who triggered the event | | `data` | object | data output from the tool | -| ↳ `id` | string | Recording UUID | -| ↳ `title` | string | Recording title | -| ↳ `start_datetime` | string | ISO8601 start timestamp | -| ↳ `end_datetime` | string | ISO8601 end timestamp | -| ↳ `duration_ms` | number | Duration in milliseconds | -| ↳ `media_type` | string | audio, transcript, or video | -| ↳ `source` | string | Recording source \(zoom, meet, local_capture, etc.\) | +| ↳ `id` | string | Story UUID | +| ↳ `title` | string | Story title | | ↳ `url` | string | URL to view in Grain | -| ↳ `thumbnail_url` | string | Thumbnail URL \(nullable\) | -| ↳ `tags` | array | Array of tag strings | -| ↳ `teams` | array | Array of team objects | -| ↳ `meeting_type` | object | Meeting type info with id, name, scope \(nullable\) | +| ↳ `created_datetime` | string | ISO8601 creation timestamp | + + +--- + +### Grain Story Deleted + +Trigger when a story is deleted in Grain + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Required to create the webhook in Grain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., recording_added\) | +| `user_id` | string | User UUID who triggered the event | +| `data` | object | Event data object \(recording, highlight, etc.\) | --- -### Grain Story Created +### Grain Story Updated -Trigger workflow when a new story is created in Grain +Trigger when a story is updated in Grain #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Required to create the webhook in Grain. | -| `viewId` | string | Yes | Required by Grain to create the webhook subscription. | #### Output @@ -487,3 +510,24 @@ Trigger workflow when a new story is created in Grain | ↳ `url` | string | URL to view in Grain | | ↳ `created_datetime` | string | ISO8601 creation timestamp | + +--- + +### Grain Upload Status + +Trigger on progress updates for recordings uploaded to Grain + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Required to create the webhook in Grain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., recording_added\) | +| `user_id` | string | User UUID who triggered the event | +| `data` | object | Event data object \(recording, highlight, etc.\) | + diff --git a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx index 2489fc1c750..ae5a8ac2d05 100644 --- a/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/hubspot-service-account.mdx @@ -116,7 +116,7 @@ The access token is bearer credentials for your entire portal, limited only by i ## Using the Service Account in Workflows -Add a HubSpot block to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. +Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, your HubSpot service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. For the polling trigger, a service account is often the better choice — the token never expires and keeps working even if the person who connected it leaves the portal. {/* TODO(screenshot): HubSpot block in a workflow with the HubSpot service account selected as the credential */} diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 248715d9577..551b8d25890 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -1,13 +1,32 @@ { "pages": [ "index", + "---Service Accounts & API Keys---", + "airtable-service-account", + "asana-service-account", + "atlassian-service-account", + "attio-service-account", + "box-service-account", + "calcom-service-account", + "google-service-account", + "hubspot-service-account", + "linear-service-account", + "monday-service-account", + "notion-service-account", + "pipedrive-service-account", + "salesforce-service-account", + "shopify-service-account", + "trello-service-account", + "wealthbox-service-account", + "webflow-service-account", + "zoom-service-account", + "---All Integrations---", "a2a", "agentmail", "agentphone", "agiloft", "ahrefs", "airtable", - "airtable-service-account", "airweave", "algolia", "amplitude", @@ -16,12 +35,9 @@ "appconfig", "arxiv", "asana", - "asana-service-account", "ashby", "athena", - "atlassian-service-account", "attio", - "attio-service-account", "azure_devops", "box", "brandfetch", @@ -30,12 +46,13 @@ "browser_use", "buffer", "calcom", - "calcom-service-account", "calendly", "circleback", "clay", "clerk", "clickhouse", + "clickup", + "clickup-service-account", "cloudflare", "cloudformation", "cloudwatch", @@ -81,7 +98,6 @@ "gitlab", "gmail", "gong", - "google-service-account", "google_ads", "google_appsheet", "google_bigquery", @@ -108,7 +124,6 @@ "greptile", "hex", "hubspot", - "hubspot-service-account", "hubspot-setup", "huggingface", "hunter", @@ -133,7 +148,6 @@ "leadmagic", "lemlist", "linear", - "linear-service-account", "linkedin", "linkup", "linq", @@ -152,14 +166,12 @@ "millionverifier", "mistral_parse", "monday", - "monday-service-account", "mongodb", "mysql", "neo4j", "neverbounce", "new_relic", "notion", - "notion-service-account", "obsidian", "okta", "onedrive", @@ -191,6 +203,7 @@ "resend", "revenuecat", "rippling", + "rocketlane", "rootly", "s3", "salesforce", @@ -206,7 +219,6 @@ "sftp", "sharepoint", "shopify", - "shopify-service-account", "similarweb", "sixtyfour", "slack", @@ -228,7 +240,6 @@ "thrive", "tinybird", "trello", - "trello-service-account", "trigger_dev", "twilio", "twilio_sms", @@ -239,9 +250,7 @@ "vanta", "vercel", "wealthbox", - "wealthbox-service-account", "webflow", - "webflow-service-account", "whatsapp", "wikipedia", "wiza", diff --git a/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx b/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx new file mode 100644 index 00000000000..821fe5a156c --- /dev/null +++ b/apps/docs/content/docs/en/integrations/pipedrive-service-account.mdx @@ -0,0 +1,74 @@ +--- +title: Pipedrive API Tokens +description: Connect Pipedrive to Sim with a personal API token instead of an OAuth login +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Pipedrive personal API tokens let your workflows authenticate to Pipedrive without a person's OAuth login. The token is issued per user per company, doesn't expire on its own, and stays valid until it's regenerated or the user is deactivated — no OAuth consent to renew. + +The token carries the full data access of the Pipedrive user it belongs to, in that one company. For production workflows, create it from a dedicated Pipedrive user so a teammate leaving or regenerating their token doesn't break your automations. + +## Prerequisites + +Any Pipedrive user with API access enabled can copy their token. If the API page is hidden, a Pipedrive admin may have disabled API access for non-admin users — an admin can enable it from the company's user settings. + + +Each user has exactly **one** active API token per company. Regenerating it immediately invalidates the old value everywhere it's used, with no grace period. If the token is shared with other integrations, coordinate before regenerating. + + +## Finding the API Token + + + + In Pipedrive, click your profile picture in the top right, then **Personal preferences** → **API** — or go directly to `https://app.pipedrive.com/settings/api` + + + Copy **Your personal API token** (generate one if the field is empty) + + + + +The API token grants everything its user can see and do in Pipedrive. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. + + +## Adding the API Token to Sim + + + + Open **Integrations** from your workspace sidebar + + + Search for "Pipedrive" and open it, then click **Add to Sim** and choose **Add API token** + + + Paste the **API token**, and optionally set a display name and description + + + Click **Add API token**. Sim verifies the token against Pipedrive (`GET /v1/users/me`) and names the credential after the Pipedrive user and company — if verification fails, you'll see a specific error explaining what went wrong. + + + +## Using the API Token in Workflows + +Add a Pipedrive block to your workflow. In the credential dropdown, your Pipedrive API token appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +Sim sends the token in Pipedrive's `x-api-token` header (the documented scheme for personal API tokens), so every Pipedrive tool works unchanged. + + +Pipedrive gives API-token traffic lower burst rate limits than OAuth (roughly a quarter of the OAuth allowance, by plan), and every company shares one daily API budget across all users and both auth methods. Heavy workflow schedules can eat into the budget your other Pipedrive integrations use. + + +## Rotating the Token + +Pipedrive tokens don't expire on a schedule. To rotate one, regenerate it on the same **Personal preferences** → **API** page, then paste the new value into the credential in Sim right away — the old token stops working the moment a new one is generated. + + diff --git a/apps/docs/content/docs/en/integrations/rocketlane.mdx b/apps/docs/content/docs/en/integrations/rocketlane.mdx new file mode 100644 index 00000000000..b011a0eced1 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/rocketlane.mdx @@ -0,0 +1,4246 @@ +--- +title: Rocketlane +description: Manage client onboarding projects, tasks, time tracking, and invoices +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Rocketlane into your workflow. Rocketlane is a professional-services automation platform for client onboarding and project delivery. Create and manage projects, tasks, phases, custom fields, time entries, time-offs, spaces, documents, resource allocations, and invoices. + + + +## Actions + +### `rocketlane_create_project` + +Create a new Rocketlane project with a customer, owner, dates, team members, templates, financials, and custom fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectName` | string | Yes | Name of the project | +| `customerCompanyName` | string | Yes | Name of the customer company \(case-sensitive exact match; cannot be changed after creation\) | +| `ownerUserId` | number | No | User ID of the project owner \(either ownerUserId or ownerEmailId must be provided\) | +| `ownerEmailId` | string | No | Email of the project owner \(either ownerUserId or ownerEmailId must be provided\) | +| `startDate` | string | No | Date on which the project begins \(YYYY-MM-DD\); required when sources are provided | +| `dueDate` | string | No | Date on which the project is planned to complete \(YYYY-MM-DD, on or after startDate\) | +| `visibility` | string | No | Who can see the project: EVERYONE or MEMBERS | +| `statusValue` | number | No | Value \(identifier\) of the project status | +| `memberUserIds` | array | No | User IDs of team members from your organization to add to the project | +| `customerUserIds` | array | No | User IDs of customer stakeholders to add to the project | +| `customerChampionUserId` | number | No | User ID of the customer champion | +| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string or number matching the field type\) | +| `sources` | array | No | Project templates to import at creation, each with templateId, startDate \(YYYY-MM-DD\), and optional prefix | +| `placeholders` | array | No | Placeholder-to-user mappings, each with placeholderId and user \(\{ userId \} or \{ emailId \}\); ignored unless the project is built from sources | +| `assignProjectOwner` | boolean | No | Automatically assign unassigned tasks to the project owner \(skipped when no sources are used\) | +| `annualizedRecurringRevenue` | number | No | Recurring revenue of the customer subscriptions for a single calendar year | +| `projectFee` | number | No | Total fee charged for the project | +| `autoAllocation` | boolean | No | Whether auto allocation is enabled for the project | +| `autoCreateCompany` | boolean | No | Create the customer company if it does not already exist | +| `budgetedHours` | number | No | Total hours allocated for project execution \(decimal, up to two places\) | +| `contractType` | string | No | Contract type for the project financials: FIXED_FEE, TIME_AND_MATERIAL, NON_BILLABLE, or SUBSCRIPTION | +| `fixedFee` | number | No | Project fee for FIXED_FEE contract type projects | +| `projectBudget` | number | No | Project budget for TIME_AND_MATERIAL contract type projects | +| `rateCardId` | number | No | Rate card ID for TIME_AND_MATERIAL contract type projects | +| `subscriptionFrequency` | string | No | Subscription renewal interval for SUBSCRIPTION contracts: MONTHLY, QUARTERLY, HALF_YEARLY, or YEARLY | +| `subscriptionStartDate` | string | No | Date when the subscription interval begins \(YYYY-MM-DD\) | +| `periodMinutes` | number | No | Budgeted minutes for each subscription period | +| `periodBudget` | number | No | Fixed budget of every subscription period | +| `noOfPeriods` | number | No | Number of periods in the subscription | +| `currency` | string | No | Currency for the project financials \(ISO code, e.g. USD\); cannot be changed once set | +| `externalReferenceId` | string | No | Identifier linking the project to an external system | +| `includeFields` | string | No | Comma-separated extra fields to return in the response \(e.g. budgetedHours,progressPercentage\) | +| `includeAllFields` | boolean | No | Return all fields in the response body | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The created project | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_get_project` + +Retrieve a Rocketlane project by its unique identifier + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | +| `includeFields` | string | No | Comma-separated extra fields to return in the response \(e.g. budgetedHours,progressPercentage\) | +| `includeAllFields` | boolean | No | Return all fields in the response body | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The requested project | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_list_projects` + +List Rocketlane projects with optional filters, sorting, and token-based pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Maximum number of projects per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | +| `includeFields` | string | No | Comma-separated extra fields to return in the response \(e.g. budgetedHours,progressPercentage\) | +| `includeAllFields` | boolean | No | Return all fields in the response body | +| `sortBy` | string | No | Field to sort by: projectName, startDate, dueDate, startDateActual, dueDateActual, annualizedRecurringRevenue, or projectFee | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | Combine filters with AND \(all\) or OR \(any\); defaults to all | +| `projectNameContains` | string | No | Only return projects whose name contains this value | +| `projectNameEquals` | string | No | Only return projects whose name exactly matches this value | +| `statusEquals` | string | No | Only return projects with this status value | +| `statusOneOf` | string | No | Comma-separated status values; returns projects matching any of them | +| `customerIdEquals` | string | No | Only return projects for this customer company ID | +| `customerIdOneOf` | string | No | Comma-separated customer company IDs; returns projects matching any of them | +| `teamMemberIdEquals` | string | No | Only return projects that include this team member ID | +| `contractTypeEquals` | string | No | Only return projects with this contract type: FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE | +| `includeArchived` | boolean | No | Whether to include archived projects in the results | +| `externalReferenceIdEquals` | string | No | Only return projects with this external reference ID | +| `startDateAfter` | string | No | Only return projects whose start date is after this date \(YYYY-MM-DD\) | +| `startDateBefore` | string | No | Only return projects whose start date is before this date \(YYYY-MM-DD\) | +| `dueDateAfter` | string | No | Only return projects whose due date is after this date \(YYYY-MM-DD\) | +| `dueDateBefore` | string | No | Only return projects whose due date is before this date \(YYYY-MM-DD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | array | List of projects | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | +| `pagination` | object | Pagination details for fetching further pages | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_project` + +Update a Rocketlane project by ID, including name, dates, visibility, owner, status, and custom fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project to update | +| `projectName` | string | No | New name of the project | +| `startDate` | string | No | Date on which the project begins \(YYYY-MM-DD\) | +| `dueDate` | string | No | Date on which the project is planned to complete \(YYYY-MM-DD, on or after startDate\) | +| `visibility` | string | No | Who can see the project: EVERYONE or MEMBERS | +| `ownerUserId` | number | No | User ID of the new project owner \(transfers ownership and revokes access for the previous owner\) | +| `ownerEmailId` | string | No | Email of the new project owner | +| `statusValue` | number | No | Value \(identifier\) of the project status | +| `fields` | array | No | Custom field assignments, each with fieldId and fieldValue \(string or number matching the field type\) | +| `annualizedRecurringRevenue` | number | No | Recurring revenue of the customer subscriptions for a single calendar year | +| `projectFee` | number | No | Total fee charged for the project | +| `autoAllocation` | boolean | No | Whether auto allocation is enabled for the project | +| `budgetedHours` | number | No | Total hours allocated for project execution \(decimal, up to two places\) | +| `externalReferenceId` | string | No | Identifier linking the project to an external system | +| `includeFields` | string | No | Comma-separated extra fields to return in the response \(e.g. budgetedHours,progressPercentage\) | +| `includeAllFields` | boolean | No | Return all fields in the response body | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The updated project | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_archive_project` + +Archive a Rocketlane project by ID, making it dormant while preserving its details and history + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project to archive | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `archived` | boolean | Whether the project was archived | +| `projectId` | number | Unique identifier of the archived project | + +### `rocketlane_delete_project` + +Permanently delete a Rocketlane project by ID (irreversible; only Admins, Super Users, and Project Owners can delete) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the project was deleted | +| `projectId` | number | Unique identifier of the deleted project | + +### `rocketlane_add_project_members` + +Add team members and customer stakeholders to a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | +| `memberUserIds` | array | No | User IDs of team members from your organization to add | +| `memberEmailIds` | array | No | Emails of team members from your organization to add | +| `customerUserIds` | array | No | User IDs of customer stakeholders to add | +| `customerEmailIds` | array | No | Emails of customer stakeholders to add | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The project with its updated team members | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_remove_project_members` + +Remove team members from a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | +| `memberUserIds` | array | No | User IDs of team members to remove \(at least one of memberUserIds or memberEmailIds is required\) | +| `memberEmailIds` | array | No | Emails of team members to remove \(at least one of memberUserIds or memberEmailIds is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The project with its updated team members | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_import_template` + +Import a project template into an existing Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project to import the template into | +| `templateId` | number | Yes | Unique identifier of the template to import | +| `startDate` | string | Yes | Date on which the template goes into effect for the project \(YYYY-MM-DD\) | +| `prefix` | string | No | Prefix distinguishing which phase or task corresponds to this template when importing multiple templates | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The project after the template import \(including its sources\) | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | + +### `rocketlane_list_placeholders` + +List the placeholders of a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `placeholders` | array | Placeholders of the project | +| ↳ `placeholderId` | number | Unique identifier of the placeholder | +| ↳ `placeholderName` | string | Name of the placeholder | +| ↳ `project` | object | Project of the placeholder | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `role` | object | Role of the placeholder | +| ↳ `roleId` | number | Unique identifier of the role | +| ↳ `roleName` | string | Name of the role | +| ↳ `placeholderType` | string | Type of the placeholder \(NATIVE or EXTERNAL\) | +| ↳ `createdAt` | number | Time when the placeholder was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the placeholder was last updated \(epoch millis\) | +| `pagination` | object | Pagination details for fetching further pages | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_assign_placeholders` + +Assign a placeholder in a Rocketlane project to a user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | +| `placeholderId` | number | Yes | Unique identifier of the placeholder to assign | +| `userId` | number | No | User ID of the project member to assign \(either userId or userEmailId must be provided; must be a customer user for CUSTOMER placeholders\) | +| `userEmailId` | string | No | Email of the project member to assign \(either userId or userEmailId must be provided\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The project after the placeholder assignment | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | +| `placeholders` | array | Placeholder-to-user mappings on the project | +| ↳ `placeholder` | object | Placeholder being mapped | +| ↳ `placeholderId` | number | Unique identifier of the placeholder | +| ↳ `placeholderName` | string | Name of the placeholder | +| ↳ `placeholderStatus` | string | Status of the placeholder \(ASSIGNED or UNASSIGNED\) | +| ↳ `user` | object | User assigned to the placeholder | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `role` | string | Role name of the assigned user | +| ↳ `hourlyCostRate` | number | Latest hourly cost rate for the placeholder | +| ↳ `costRateCurrency` | string | Currency for the cost rate | + +### `rocketlane_unassign_placeholders` + +Unassign a placeholder from its user in a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | Unique identifier of the project | +| `placeholderId` | number | Yes | Unique identifier of the placeholder to unassign | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `project` | object | The project after the placeholder was unassigned | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Date on which the project execution begins \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date on which the project execution is planned to complete \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time when the project was created \(epoch millis\) | +| ↳ `updatedAt` | number | Time when the project was last updated \(epoch millis\) | +| ↳ `owner` | object | Project owner | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `teamMembers` | object | Project members, customers, and customer champion | +| ↳ `members` | array | Team members working on the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customers` | array | Customer stakeholders involved in the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `customerChampion` | object | Customer champion of the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Project status value and label | +| ↳ `value` | number | Unique identifier of the status | +| ↳ `label` | string | Name of the status | +| ↳ `fields` | array | Custom project field values | +| ↳ `fieldId` | number | Unique identifier of the custom field | +| ↳ `fieldLabel` | string | Name of the custom project field | +| ↳ `fieldValue` | string | Value assigned to the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `customer` | object | Customer company of the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `partnerCompanies` | array | Partner companies on the project | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `companyUrl` | string | Website URL of the company | +| ↳ `archived` | boolean | Whether the project is archived | +| ↳ `visibility` | string | Project visibility \(EVERYONE, MEMBERS, or GROUP\) | +| ↳ `createdBy` | object | Team member who created the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the project | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `currency` | string | Currency for the project financials \(ISO code\) | +| ↳ `financials` | object | Project financials \(contract type and per-contract-type fields\) | +| ↳ `contractType` | string | Contract type for the project financials \(FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE\) | +| ↳ `revenueRecognitionType` | string | Method used for revenue recognition | +| ↳ `fixedFee` | number | Project fee for Fixed fee contract type projects | +| ↳ `projectBudget` | number | Budget allocated for Time & Material contract type projects | +| ↳ `rateCardId` | number | Unique identifier of the rate card | +| ↳ `rateCardName` | string | Name of the rate card | +| ↳ `subscriptionFrequency` | string | Interval at which the subscription renews \(MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY\) | +| ↳ `subscriptionStartDate` | string | Date when the subscription interval begins \(YYYY-MM-DD\) | +| ↳ `periodMinutes` | number | Budgeted minutes for each subscription period | +| ↳ `periodBudget` | number | Fixed budget of every subscription period | +| ↳ `noOfPeriods` | number | Number of periods in the subscription | +| ↳ `startDateActual` | string | Date on which the project status changed to in progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date on which the project status changed to completed \(YYYY-MM-DD\) | +| ↳ `annualizedRecurringRevenue` | number | Recurring revenue of the customer subscriptions for a single calendar year | +| ↳ `projectFee` | number | Total fee charged for the project | +| ↳ `budgetedHours` | number | Total hours allocated for project execution | +| ↳ `percentageBudgetedHoursConsumed` | number | Budgeted hours consumed percentage | +| ↳ `percentageBudgetConsumed` | number | Budget consumed percentage | +| ↳ `trackedHours` | number | Hours tracked as part of submitted time entries | +| ↳ `trackedMinutes` | number | Minutes tracked as part of submitted time entries | +| ↳ `allocatedHours` | number | Allocated hours against users or placeholders | +| ↳ `allocatedMinutes` | number | Allocated minutes against users or placeholders | +| ↳ `billableHours` | number | Hours of time entries tracked as billable | +| ↳ `billableMinutes` | number | Minutes of time entries tracked as billable | +| ↳ `nonBillableHours` | number | Hours of time entries tracked as non-billable | +| ↳ `nonBillableMinutes` | number | Minutes of time entries tracked as non-billable | +| ↳ `remainingHours` | number | Hours left to complete the project based on tracked and budgeted hours | +| ↳ `remainingMinutes` | number | Minutes left to complete the project \(complements remainingHours\) | +| ↳ `progressPercentage` | number | Progress based on completed tasks vs total tasks | +| ↳ `currentPhases` | array | Phases currently marked as in progress | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `autoAllocation` | boolean | Whether auto allocation is enabled for the project | +| ↳ `sources` | array | Project templates imported into the project | +| ↳ `prefix` | string | Prefix distinguishing which phase or task corresponds to which template | +| ↳ `startDate` | string | Date on which the template goes into effect \(YYYY-MM-DD\) | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `plannedDurationInDays` | number | Difference between startDate and dueDate in days | +| ↳ `inferredProgress` | string | Inferred progress \(ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE\) | +| ↳ `projectAgeInDays` | number | Age of the project in days based on actual dates | +| ↳ `customersInvited` | number | Number of customers invited to the project | +| ↳ `customersJoined` | number | Number of customers who joined the project | +| ↳ `externalReferenceId` | string | Identifier linking the project to an external system | +| `placeholders` | array | Placeholder-to-user mappings on the project | +| ↳ `placeholder` | object | Placeholder being mapped | +| ↳ `placeholderId` | number | Unique identifier of the placeholder | +| ↳ `placeholderName` | string | Name of the placeholder | +| ↳ `placeholderStatus` | string | Status of the placeholder \(ASSIGNED or UNASSIGNED\) | +| ↳ `user` | object | User assigned to the placeholder | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `role` | string | Role name of the assigned user | +| ↳ `hourlyCostRate` | number | Latest hourly cost rate for the placeholder | +| ↳ `costRateCurrency` | string | Currency for the cost rate | + +### `rocketlane_create_task` + +Create a new task in a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskName` | string | Yes | Name of the task | +| `projectId` | number | Yes | ID of the project the task belongs to | +| `taskDescription` | string | No | Description of the task in HTML format | +| `taskPrivateNote` | string | No | Private note visible only to team members, in HTML format | +| `startDate` | string | No | Date when the task starts \(YYYY-MM-DD\) | +| `dueDate` | string | No | Date when the task is due, on or after the start date \(YYYY-MM-DD\) | +| `effortInMinutes` | number | No | Expected effort to complete the task, in minutes | +| `progress` | number | No | Progress of the task \(0-100\) | +| `atRisk` | boolean | No | Whether the task is marked as At Risk | +| `type` | string | No | Type of the task: TASK or MILESTONE \(defaults to TASK\) | +| `phaseId` | number | No | ID of the phase to associate the task with \(must belong to the project\) | +| `statusValue` | number | No | Status value to set on the task | +| `assigneeUserIds` | array | No | User IDs of members to assign to the task | +| `assigneeEmailIds` | array | No | Email addresses of members to assign to the task | +| `followerUserIds` | array | No | User IDs of members to add as followers of the task | +| `followerEmailIds` | array | No | Email addresses of members to add as followers of the task | +| `parentTaskId` | number | No | ID of the parent task | +| `externalReferenceId` | string | No | External reference identifier linking the task to an external system | +| `private` | boolean | No | Whether the task is private | +| `includeFields` | array | No | Extra fields to include in the response \(startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The created task | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_get_task` + +Retrieve detailed information about a Rocketlane task by its unique identifier + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to retrieve | +| `includeFields` | array | No | Extra fields to include in the response \(startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The requested task | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_list_tasks` + +Retrieve all Rocketlane tasks with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Number of tasks per page \(defaults to 100\) | +| `pageToken` | string | No | Token pointing to the next page of results, from a previous response | +| `includeFields` | array | No | Extra fields to include in the response \(startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | +| `sortBy` | string | No | Field to sort by: taskName, startDate, dueDate, startDateActual, or dueDateActual | +| `sortOrder` | string | No | Sort order: ASC or DESC | +| `match` | string | No | How multiple filters combine: all \(AND\) or any \(OR\) | +| `projectId` | number | No | Filter tasks by project ID | +| `phaseId` | number | No | Filter tasks by phase ID | +| `taskName` | string | No | Filter tasks whose name exactly matches this value | +| `taskNameContains` | string | No | Filter tasks whose name contains this value | +| `taskStatus` | string | No | Filter tasks by task status value | +| `startDateFrom` | string | No | Filter tasks with a start date on or after this date \(YYYY-MM-DD\) | +| `startDateTo` | string | No | Filter tasks with a start date on or before this date \(YYYY-MM-DD\) | +| `dueDateFrom` | string | No | Filter tasks with a due date on or after this date \(YYYY-MM-DD\) | +| `dueDateTo` | string | No | Filter tasks with a due date on or before this date \(YYYY-MM-DD\) | +| `includeArchive` | boolean | No | Whether archived tasks should be included in the results | +| `externalReferenceId` | string | No | Filter tasks by external reference identifier | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | List of tasks matching the filters | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_task` + +Update the properties of an existing Rocketlane task by its unique identifier + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to update | +| `taskName` | string | No | New name of the task | +| `taskDescription` | string | No | Description of the task in HTML format | +| `taskPrivateNote` | string | No | Private note visible only to team members, in HTML format | +| `startDate` | string | No | Date when the task starts \(YYYY-MM-DD\) | +| `dueDate` | string | No | Date when the task is due, on or after the start date \(YYYY-MM-DD\) | +| `effortInMinutes` | number | No | Expected effort to complete the task, in minutes | +| `progress` | number | No | Progress of the task \(0-100\) | +| `atRisk` | boolean | No | Whether the task is marked as At Risk | +| `type` | string | No | Type of the task: TASK or MILESTONE | +| `statusValue` | number | No | Status value to set on the task | +| `externalReferenceId` | string | No | External reference identifier linking the task to an external system | +| `private` | boolean | No | Whether the task is private | +| `includeFields` | array | No | Extra fields to include in the response \(startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The updated task | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_delete_task` + +Permanently delete a Rocketlane task by its unique identifier + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the task was deleted | +| `taskId` | number | ID of the deleted task | + +### `rocketlane_move_task_to_phase` + +Move a Rocketlane task to a given phase, associating the task with that phase + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to move | +| `phaseId` | number | Yes | Unique identifier of the phase to move the task to | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated phase | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_add_task_assignees` + +Add members as assignees to a Rocketlane task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to add assignees to | +| `memberUserIds` | array | No | User IDs of members to add as assignees | +| `memberEmailIds` | array | No | Email addresses of members to add as assignees | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated assignees | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_remove_task_assignees` + +Remove assignees from a Rocketlane task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to remove assignees from | +| `memberUserIds` | array | No | User IDs of members to remove from the assignees | +| `memberEmailIds` | array | No | Email addresses of members to remove from the assignees | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated assignees | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_add_task_followers` + +Add members as followers to a Rocketlane task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to add followers to | +| `memberUserIds` | array | No | User IDs of members to add as followers | +| `memberEmailIds` | array | No | Email addresses of members to add as followers | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated followers | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_remove_task_followers` + +Remove followers from a Rocketlane task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to remove followers from | +| `memberUserIds` | array | No | User IDs of members to remove from the followers | +| `memberEmailIds` | array | No | Email addresses of members to remove from the followers | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated followers | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_add_task_dependencies` + +Add finish-to-start dependencies between a Rocketlane task and other tasks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to add dependencies to | +| `dependencyTaskIds` | array | Yes | Task IDs the task should depend on | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated dependencies | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_remove_task_dependencies` + +Remove dependencies from a Rocketlane task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `taskId` | number | Yes | Unique identifier of the task to remove dependencies from | +| `dependencyTaskIds` | array | Yes | Task IDs to remove from the task dependencies | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | object | The task with its updated dependencies | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `taskDescription` | string | Description of the task in HTML format | +| ↳ `taskPrivateNote` | string | Private note visible only to team members, in HTML format | +| ↳ `startDate` | string | Date when the task starts \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Date when the task is due \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Date the task status changed to In Progress \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Date the task status changed to Completed \(YYYY-MM-DD\) | +| ↳ `archived` | boolean | Whether the task is archived | +| ↳ `effortInMinutes` | number | Expected effort to complete the task, in minutes | +| ↳ `progress` | number | Progress of the task \(0-100\) | +| ↳ `atRisk` | boolean | Whether the task is marked as At Risk | +| ↳ `type` | string | Type of the task: TASK or MILESTONE | +| ↳ `createdAt` | number | Time the task was created, in epoch millis | +| ↳ `updatedAt` | number | Time the task was last updated, in epoch millis | +| ↳ `createdBy` | object | User who created the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `project` | object | Project associated with the task | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `phase` | object | Phase associated with the task | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `status` | object | Status of the task \(value and label\) | +| ↳ `priority` | object | Priority of the task \(value and label\) | +| ↳ `fields` | array | Custom field values set on the task | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `assignees` | object | Assignees of the task \(members and placeholders\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `followers` | object | Followers of the task | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `dependencies` | array | Tasks this task depends on \(finish-to-start dependencies\) | +| ↳ `parent` | object | Parent task of the task | +| ↳ `externalReferenceId` | string | External reference identifier linking the task to an external system | +| ↳ `billable` | boolean | Whether the task is billable | +| ↳ `timeEntryCategory` | object | Category in which the task time entries are added | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `financialsBudgets` | array | Financials budgets in which the task time entries are added | +| ↳ `budgetId` | number | Unique identifier of the budget | +| ↳ `budgetName` | string | Name of the budget | +| ↳ `csatEnabled` | boolean | Whether a CSAT survey is sent on completion \(milestone tasks\) | +| ↳ `private` | boolean | Whether the task is private | + +### `rocketlane_create_phase` + +Create a new phase in a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `phaseName` | string | Yes | Name of the phase | +| `projectId` | number | Yes | ID of the project to create the phase in | +| `startDate` | string | Yes | Planned start date of the phase \(YYYY-MM-DD\) | +| `dueDate` | string | Yes | Planned due date of the phase \(YYYY-MM-DD\) | +| `statusValue` | number | No | Numeric status value to set on the phase | +| `private` | boolean | No | Whether the phase is private | +| `includeFields` | string | No | Comma-separated extra phase properties to include in the response \(supported: startDateActual, dueDateActual\) | +| `includeAllFields` | boolean | No | Whether to return all phase properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phase` | object | The created phase | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `project` | object | Project the phase belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Planned start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Planned due date of the phase \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Actual start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Actual due date of the phase \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time the phase was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the phase was last updated, in epoch milliseconds | +| ↳ `createdBy` | object | Team member who created the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Status of the phase | +| ↳ `value` | number | Numeric status value | +| ↳ `label` | string | Display label of the status | +| ↳ `private` | boolean | Whether the phase is private | + +### `rocketlane_get_phase` + +Retrieve a single Rocketlane phase by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `phaseId` | number | Yes | ID of the phase to retrieve | +| `includeFields` | string | No | Comma-separated extra phase properties to include in the response \(supported: startDateActual, dueDateActual\) | +| `includeAllFields` | boolean | No | Whether to return all phase properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phase` | object | The requested phase | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `project` | object | Project the phase belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Planned start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Planned due date of the phase \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Actual start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Actual due date of the phase \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time the phase was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the phase was last updated, in epoch milliseconds | +| ↳ `createdBy` | object | Team member who created the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Status of the phase | +| ↳ `value` | number | Numeric status value | +| ↳ `label` | string | Display label of the status | +| ↳ `private` | boolean | Whether the phase is private | + +### `rocketlane_list_phases` + +List phases of a Rocketlane project, with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | ID of the project to list phases for | +| `pageSize` | number | No | Number of phases per page | +| `pageToken` | string | No | Page token returned by a previous request \(valid for 15 minutes\) | +| `includeFields` | string | No | Comma-separated extra phase properties to include in the response \(supported: startDateActual, dueDateActual\) | +| `includeAllFields` | boolean | No | Whether to return all phase properties in the response | +| `sortBy` | string | No | Property to sort by \(phaseName, startDate, dueDate, startDateActual, dueDateActual\) | +| `sortOrder` | string | No | Sort order \(ASC or DESC\) | +| `match` | string | No | Whether results must match all filters or any filter \(all or any\) | +| `phaseName` | string | No | Filter by exact phase name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phases` | array | List of phases | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `project` | object | Project the phase belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Planned start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Planned due date of the phase \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Actual start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Actual due date of the phase \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time the phase was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the phase was last updated, in epoch milliseconds | +| ↳ `createdBy` | object | Team member who created the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Status of the phase | +| ↳ `value` | number | Numeric status value | +| ↳ `label` | string | Display label of the status | +| ↳ `private` | boolean | Whether the phase is private | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_phase` + +Update the name, dates, status, or privacy of an existing Rocketlane phase + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `phaseId` | number | Yes | ID of the phase to update | +| `phaseName` | string | No | New name of the phase | +| `startDate` | string | No | New planned start date of the phase \(YYYY-MM-DD\) | +| `dueDate` | string | No | New planned due date of the phase \(YYYY-MM-DD\) | +| `statusValue` | number | No | New numeric status value for the phase | +| `private` | boolean | No | Whether the phase is private | +| `includeFields` | string | No | Comma-separated extra phase properties to include in the response \(supported: startDateActual, dueDateActual\) | +| `includeAllFields` | boolean | No | Whether to return all phase properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phase` | object | The updated phase | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `project` | object | Project the phase belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `startDate` | string | Planned start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Planned due date of the phase \(YYYY-MM-DD\) | +| ↳ `startDateActual` | string | Actual start date of the phase \(YYYY-MM-DD\) | +| ↳ `dueDateActual` | string | Actual due date of the phase \(YYYY-MM-DD\) | +| ↳ `createdAt` | number | Time the phase was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the phase was last updated, in epoch milliseconds | +| ↳ `createdBy` | object | Team member who created the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the phase | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `status` | object | Status of the phase | +| ↳ `value` | number | Numeric status value | +| ↳ `label` | string | Display label of the status | +| ↳ `private` | boolean | Whether the phase is private | + +### `rocketlane_delete_phase` + +Permanently delete a Rocketlane phase by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `phaseId` | number | Yes | ID of the phase to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the phase was deleted | +| `phaseId` | number | ID of the deleted phase | + +### `rocketlane_create_field` + +Create a custom field in your Rocketlane account, with options for SINGLE_CHOICE and MULTIPLE_CHOICE field types + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldLabel` | string | Yes | Name of the field | +| `fieldType` | string | Yes | Type of the field \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| `objectType` | string | Yes | Object the field is associated with \(PROJECT, TASK, or USER\) | +| `fieldDescription` | string | No | Description of the field | +| `fieldOptions` | array | No | Options for SINGLE_CHOICE and MULTIPLE_CHOICE fields; the order provided is preserved | +| `ratingScale` | string | No | Number of stars for RATING fields \(THREE, FIVE, SEVEN, TEN\) | +| `enabled` | boolean | No | Whether the field is enabled \(defaults to true\) | +| `private` | boolean | No | Whether the field is private | +| `includeFields` | string | No | Comma-separated extra field properties to include in the response \(supported: options\) | +| `includeAllFields` | boolean | No | Whether to return all field properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `field` | object | The created field | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the field | +| ↳ `fieldDescription` | string | Description of the field | +| ↳ `fieldType` | string | Type of the field \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| ↳ `objectType` | string | Object the field is associated with \(PROJECT, TASK, or USER\) | +| ↳ `fieldOptions` | array | Options available for SINGLE_CHOICE and MULTIPLE_CHOICE fields | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | +| ↳ `ratingScale` | string | Rating scale for RATING fields \(THREE, FIVE, SEVEN, TEN\) | +| ↳ `createdBy` | object | Team member who created the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Time the field was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the field was last updated, in epoch milliseconds | +| ↳ `enabled` | boolean | Whether the field is enabled | +| ↳ `private` | boolean | Whether the field is private | + +### `rocketlane_get_field` + +Retrieve a single Rocketlane field by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldId` | number | Yes | ID of the field to retrieve | +| `includeFields` | string | No | Comma-separated extra field properties to include in the response \(supported: options\) | +| `includeAllFields` | boolean | No | Whether to return all field properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `field` | object | The requested field | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the field | +| ↳ `fieldDescription` | string | Description of the field | +| ↳ `fieldType` | string | Type of the field \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| ↳ `objectType` | string | Object the field is associated with \(PROJECT, TASK, or USER\) | +| ↳ `fieldOptions` | array | Options available for SINGLE_CHOICE and MULTIPLE_CHOICE fields | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | +| ↳ `ratingScale` | string | Rating scale for RATING fields \(THREE, FIVE, SEVEN, TEN\) | +| ↳ `createdBy` | object | Team member who created the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Time the field was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the field was last updated, in epoch milliseconds | +| ↳ `enabled` | boolean | Whether the field is enabled | +| ↳ `private` | boolean | Whether the field is private | + +### `rocketlane_list_fields` + +List fields in your Rocketlane account, with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Number of fields per page | +| `pageToken` | string | No | Page token returned by a previous request \(valid for 15 minutes\) | +| `includeFields` | string | No | Comma-separated extra field properties to include in the response \(supported: options\) | +| `includeAllFields` | boolean | No | Whether to return all field properties in the response | +| `sortBy` | string | No | Property to sort by \(supported: fieldLabel\) | +| `sortOrder` | string | No | Sort order \(ASC or DESC\) | +| `match` | string | No | Whether results must match all filters or any filter \(all or any\) | +| `objectType` | string | No | Filter by associated object type \(PROJECT, TASK, or USER\) | +| `fieldType` | string | No | Filter by field type \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| `enabled` | boolean | No | Filter by enabled state | +| `private` | boolean | No | Filter by privacy setting | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fields` | array | List of fields | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the field | +| ↳ `fieldDescription` | string | Description of the field | +| ↳ `fieldType` | string | Type of the field \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| ↳ `objectType` | string | Object the field is associated with \(PROJECT, TASK, or USER\) | +| ↳ `fieldOptions` | array | Options available for SINGLE_CHOICE and MULTIPLE_CHOICE fields | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | +| ↳ `ratingScale` | string | Rating scale for RATING fields \(THREE, FIVE, SEVEN, TEN\) | +| ↳ `createdBy` | object | Team member who created the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Time the field was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the field was last updated, in epoch milliseconds | +| ↳ `enabled` | boolean | Whether the field is enabled | +| ↳ `private` | boolean | Whether the field is private | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_field` + +Update the label, description, enabled state, or privacy of an existing Rocketlane field + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldId` | number | Yes | ID of the field to update | +| `fieldLabel` | string | No | New name of the field | +| `fieldDescription` | string | No | New description of the field | +| `enabled` | boolean | No | Whether the field is enabled | +| `private` | boolean | No | Whether the field is private | +| `includeFields` | string | No | Comma-separated extra field properties to include in the response \(supported: options\) | +| `includeAllFields` | boolean | No | Whether to return all field properties in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `field` | object | The updated field | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the field | +| ↳ `fieldDescription` | string | Description of the field | +| ↳ `fieldType` | string | Type of the field \(TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING\) | +| ↳ `objectType` | string | Object the field is associated with \(PROJECT, TASK, or USER\) | +| ↳ `fieldOptions` | array | Options available for SINGLE_CHOICE and MULTIPLE_CHOICE fields | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | +| ↳ `ratingScale` | string | Rating scale for RATING fields \(THREE, FIVE, SEVEN, TEN\) | +| ↳ `createdBy` | object | Team member who created the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | Team member who last updated the field | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Time the field was created, in epoch milliseconds | +| ↳ `updatedAt` | number | Time the field was last updated, in epoch milliseconds | +| ↳ `enabled` | boolean | Whether the field is enabled | +| ↳ `private` | boolean | Whether the field is private | + +### `rocketlane_delete_field` + +Permanently delete a Rocketlane field by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldId` | number | Yes | ID of the field to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the field was deleted | +| `fieldId` | number | ID of the deleted field | + +### `rocketlane_add_field_option` + +Add a new option to an existing SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldId` | number | Yes | ID of the field to add the option to | +| `optionLabel` | string | Yes | Display label of the new option | +| `optionColor` | string | Yes | Color of the new option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `option` | object | The created field option | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | + +### `rocketlane_update_field_option` + +Update the label or color of an existing option on a SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `fieldId` | number | Yes | ID of the field the option belongs to | +| `optionValue` | number | Yes | Unique identifier of the option to update | +| `optionLabel` | string | No | New display label of the option | +| `optionColor` | string | No | New color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `option` | object | The updated field option | +| ↳ `optionValue` | number | Unique identifier of the option within the field | +| ↳ `optionLabel` | string | Display label of the option | +| ↳ `optionColor` | string | Color of the option \(RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY\) | + +### `rocketlane_create_time_entry` + +Create a time entry in Rocketlane against an adhoc activity, task, project phase, or project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `date` | string | Yes | Date of the time entry in YYYY-MM-DD format | +| `minutes` | number | Yes | Duration of the time entry in minutes \(1-1440\) | +| `activityName` | string | No | Name of the adhoc activity to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error | +| `taskId` | number | No | ID of the task to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error | +| `projectPhaseId` | number | No | ID of the project phase to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error | +| `projectId` | number | No | ID of the project to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error | +| `billable` | boolean | No | Whether the time entry is billable \(defaults to true\) | +| `userId` | number | No | ID of the user the time entry belongs to | +| `userEmail` | string | No | Email of the user the time entry belongs to \(alternative to userId\) | +| `notes` | string | No | Notes for the time entry | +| `categoryId` | number | No | ID of the time entry category | +| `includeFields` | string | No | Comma-separated extra fields to include in the response \(notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | object | The created time entry | +| ↳ `timeEntryId` | number | Unique identifier of the time entry | +| ↳ `date` | string | Date of the time entry \(YYYY-MM-DD\) | +| ↳ `minutes` | number | Duration of the time entry in minutes | +| ↳ `activityName` | string | Name of the adhoc activity, when the entry is tracked against an activity | +| ↳ `project` | object | Project associated with the time entry | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `task` | object | Task associated with the time entry | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `projectPhase` | object | Project phase associated with the time entry | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `billable` | boolean | Whether the time entry is billable | +| ↳ `user` | object | User the time entry belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `notes` | string | Notes for the time entry | +| ↳ `category` | object | Category associated with the time entry | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `sourceType` | string | Source of the time entry \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| ↳ `status` | string | Approval status of the time entry \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| ↳ `createdAt` | number | Creation timestamp in epoch milliseconds | +| ↳ `updatedAt` | number | Last-updated timestamp in epoch milliseconds | +| ↳ `createdBy` | object | User who created the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedBy` | object | User who submitted the time entry \(may be null even for approved/rejected entries\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedAt` | number | Submission timestamp in epoch milliseconds | +| ↳ `approvedBy` | object | User who approved the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `approvedAt` | number | Approval timestamp in epoch milliseconds | +| ↳ `rejectedBy` | object | User who rejected the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `rejectedAt` | number | Rejection timestamp in epoch milliseconds | +| ↳ `deleted` | boolean | Whether the time entry is deleted | +| ↳ `costRate` | object | Hourly cost rate assigned to the user for this entry | +| ↳ `billRate` | object | Hourly rate billed to the customer for this entry | +| ↳ `fields` | array | Custom fields associated with the time entry | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | + +### `rocketlane_get_time_entry` + +Get a single Rocketlane time entry by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `timeEntryId` | number | Yes | ID of the time entry to retrieve | +| `includeFields` | string | No | Comma-separated extra fields to include in the response \(notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | object | The requested time entry | +| ↳ `timeEntryId` | number | Unique identifier of the time entry | +| ↳ `date` | string | Date of the time entry \(YYYY-MM-DD\) | +| ↳ `minutes` | number | Duration of the time entry in minutes | +| ↳ `activityName` | string | Name of the adhoc activity, when the entry is tracked against an activity | +| ↳ `project` | object | Project associated with the time entry | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `task` | object | Task associated with the time entry | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `projectPhase` | object | Project phase associated with the time entry | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `billable` | boolean | Whether the time entry is billable | +| ↳ `user` | object | User the time entry belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `notes` | string | Notes for the time entry | +| ↳ `category` | object | Category associated with the time entry | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `sourceType` | string | Source of the time entry \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| ↳ `status` | string | Approval status of the time entry \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| ↳ `createdAt` | number | Creation timestamp in epoch milliseconds | +| ↳ `updatedAt` | number | Last-updated timestamp in epoch milliseconds | +| ↳ `createdBy` | object | User who created the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedBy` | object | User who submitted the time entry \(may be null even for approved/rejected entries\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedAt` | number | Submission timestamp in epoch milliseconds | +| ↳ `approvedBy` | object | User who approved the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `approvedAt` | number | Approval timestamp in epoch milliseconds | +| ↳ `rejectedBy` | object | User who rejected the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `rejectedAt` | number | Rejection timestamp in epoch milliseconds | +| ↳ `deleted` | boolean | Whether the time entry is deleted | +| ↳ `costRate` | object | Hourly cost rate assigned to the user for this entry | +| ↳ `billRate` | object | Hourly rate billed to the customer for this entry | +| ↳ `fields` | array | Custom fields associated with the time entry | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | + +### `rocketlane_list_time_entries` + +List Rocketlane time entries with optional filters, sorting, and cursor-based pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `dateEq` | string | No | Only entries on this exact date \(YYYY-MM-DD\) | +| `dateGt` | string | No | Only entries after this date \(YYYY-MM-DD\) | +| `dateGe` | string | No | Only entries on or after this date \(YYYY-MM-DD\) | +| `dateLt` | string | No | Only entries before this date \(YYYY-MM-DD\) | +| `dateLe` | string | No | Only entries on or before this date \(YYYY-MM-DD\) | +| `projectIdEq` | number | No | Only entries for this project ID | +| `taskIdEq` | number | No | Only entries for this task ID | +| `projectPhaseIdEq` | number | No | Only entries for this project phase ID | +| `categoryIdEq` | number | No | Only entries with this category ID | +| `userIdEq` | number | No | Only entries belonging to this user ID | +| `emailIdEq` | string | No | Only entries belonging to the user with this exact email | +| `emailIdCn` | string | No | Only entries whose user email contains this text | +| `sourceTypeEq` | string | No | Only entries with this source type \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| `activityNameEq` | string | No | Only entries with this exact activity name | +| `activityNameCn` | string | No | Only entries whose activity name contains this text | +| `approvalStatusEq` | string | No | Only entries with this approval status \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| `billableEq` | boolean | No | Only billable \(true\) or non-billable \(false\) entries | +| `includeDeletedEq` | boolean | No | Whether deleted time entries are included in the response | +| `submittedByEq` | number | No | Only entries submitted by this user ID | +| `approvedByEq` | number | No | Only entries approved by this user ID | +| `rejectedByEq` | number | No | Only entries rejected by this user ID | +| `createdAtGt` | number | No | Only entries created after this epoch-millisecond timestamp | +| `createdAtLt` | number | No | Only entries created before this epoch-millisecond timestamp | +| `updatedAtGt` | number | No | Only entries updated after this epoch-millisecond timestamp | +| `updatedAtLt` | number | No | Only entries updated before this epoch-millisecond timestamp | +| `match` | string | No | How to combine filters: all \(AND, default\) or any \(OR\) | +| `sortBy` | string | No | Field to sort by \(minutes, date, id, billable\) | +| `sortOrder` | string | No | Sort order: ASC or DESC \(default DESC\) | +| `includeFields` | string | No | Comma-separated extra fields to include in the response \(notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate\) | +| `pageSize` | number | No | Maximum number of entries per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response for fetching the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntries` | array | List of time entries matching the filters | +| ↳ `timeEntryId` | number | Unique identifier of the time entry | +| ↳ `date` | string | Date of the time entry \(YYYY-MM-DD\) | +| ↳ `minutes` | number | Duration of the time entry in minutes | +| ↳ `activityName` | string | Name of the adhoc activity, when the entry is tracked against an activity | +| ↳ `project` | object | Project associated with the time entry | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `task` | object | Task associated with the time entry | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `projectPhase` | object | Project phase associated with the time entry | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `billable` | boolean | Whether the time entry is billable | +| ↳ `user` | object | User the time entry belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `notes` | string | Notes for the time entry | +| ↳ `category` | object | Category associated with the time entry | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `sourceType` | string | Source of the time entry \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| ↳ `status` | string | Approval status of the time entry \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| ↳ `createdAt` | number | Creation timestamp in epoch milliseconds | +| ↳ `updatedAt` | number | Last-updated timestamp in epoch milliseconds | +| ↳ `createdBy` | object | User who created the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedBy` | object | User who submitted the time entry \(may be null even for approved/rejected entries\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedAt` | number | Submission timestamp in epoch milliseconds | +| ↳ `approvedBy` | object | User who approved the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `approvedAt` | number | Approval timestamp in epoch milliseconds | +| ↳ `rejectedBy` | object | User who rejected the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `rejectedAt` | number | Rejection timestamp in epoch milliseconds | +| ↳ `deleted` | boolean | Whether the time entry is deleted | +| ↳ `costRate` | object | Hourly cost rate assigned to the user for this entry | +| ↳ `billRate` | object | Hourly rate billed to the customer for this entry | +| ↳ `fields` | array | Custom fields associated with the time entry | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_search_time_entries` + +Search Rocketlane time entries with filters, sorting, and pagination (deprecated by Rocketlane in favor of listing time entries with filters) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `dateEq` | string | No | Only entries on this exact date \(YYYY-MM-DD\) | +| `dateGt` | string | No | Only entries after this date \(YYYY-MM-DD\) | +| `dateGe` | string | No | Only entries on or after this date \(YYYY-MM-DD\) | +| `dateLt` | string | No | Only entries before this date \(YYYY-MM-DD\) | +| `dateLe` | string | No | Only entries on or before this date \(YYYY-MM-DD\) | +| `projectEq` | number | No | Only entries for this project ID | +| `taskEq` | number | No | Only entries for this task ID | +| `projectPhaseIdEq` | number | No | Only entries for this project phase ID | +| `categoryIdEq` | number | No | Only entries with this category ID | +| `userIdEq` | number | No | Only entries belonging to this user ID | +| `sourceTypeEq` | string | No | Only entries with this source type \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| `activityNameEq` | string | No | Only entries with this exact activity name | +| `activityNameCn` | string | No | Only entries whose activity name contains this text | +| `approvalStatusEq` | string | No | Only entries with this approval status \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| `match` | string | No | How to combine filters: all \(AND, default\) or any \(OR\) | +| `sortBy` | string | No | Field to sort by \(minutes, date, id, billable\) | +| `sortOrder` | string | No | Sort order: ASC or DESC \(default DESC\) | +| `includeFields` | string | No | Comma-separated extra fields to include in the response \(notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | +| `pageSize` | number | No | Maximum number of entries per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response for fetching the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntries` | array | List of time entries matching the search filters | +| ↳ `timeEntryId` | number | Unique identifier of the time entry | +| ↳ `date` | string | Date of the time entry \(YYYY-MM-DD\) | +| ↳ `minutes` | number | Duration of the time entry in minutes | +| ↳ `activityName` | string | Name of the adhoc activity, when the entry is tracked against an activity | +| ↳ `project` | object | Project associated with the time entry | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `task` | object | Task associated with the time entry | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `projectPhase` | object | Project phase associated with the time entry | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `billable` | boolean | Whether the time entry is billable | +| ↳ `user` | object | User the time entry belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `notes` | string | Notes for the time entry | +| ↳ `category` | object | Category associated with the time entry | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `sourceType` | string | Source of the time entry \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| ↳ `status` | string | Approval status of the time entry \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| ↳ `createdAt` | number | Creation timestamp in epoch milliseconds | +| ↳ `updatedAt` | number | Last-updated timestamp in epoch milliseconds | +| ↳ `createdBy` | object | User who created the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedBy` | object | User who submitted the time entry \(may be null even for approved/rejected entries\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedAt` | number | Submission timestamp in epoch milliseconds | +| ↳ `approvedBy` | object | User who approved the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `approvedAt` | number | Approval timestamp in epoch milliseconds | +| ↳ `rejectedBy` | object | User who rejected the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `rejectedAt` | number | Rejection timestamp in epoch milliseconds | +| ↳ `deleted` | boolean | Whether the time entry is deleted | +| ↳ `costRate` | object | Hourly cost rate assigned to the user for this entry | +| ↳ `billRate` | object | Hourly rate billed to the customer for this entry | +| ↳ `fields` | array | Custom fields associated with the time entry | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_time_entry` + +Update a Rocketlane time entry by ID. The activityName, notes, billable, and minutes properties can be updated + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `timeEntryId` | number | Yes | ID of the time entry to update | +| `date` | string | Yes | Date of the time entry in YYYY-MM-DD format \(mandatory so the total time for the date does not exceed 24 hours\) | +| `minutes` | number | Yes | Duration of the time entry in minutes \(1-1440\) | +| `activityName` | string | No | New name for the adhoc activity | +| `notes` | string | No | New notes for the time entry | +| `billable` | boolean | No | Whether the time entry is billable | +| `categoryId` | number | No | ID of the time entry category | +| `includeFields` | string | No | Comma-separated extra fields to include in the response \(notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate\) | +| `includeAllFields` | boolean | No | Whether all fields should be returned in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeEntry` | object | The updated time entry | +| ↳ `timeEntryId` | number | Unique identifier of the time entry | +| ↳ `date` | string | Date of the time entry \(YYYY-MM-DD\) | +| ↳ `minutes` | number | Duration of the time entry in minutes | +| ↳ `activityName` | string | Name of the adhoc activity, when the entry is tracked against an activity | +| ↳ `project` | object | Project associated with the time entry | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `task` | object | Task associated with the time entry | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `projectPhase` | object | Project phase associated with the time entry | +| ↳ `phaseId` | number | Unique identifier of the phase | +| ↳ `phaseName` | string | Name of the phase | +| ↳ `billable` | boolean | Whether the time entry is billable | +| ↳ `user` | object | User the time entry belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `notes` | string | Notes for the time entry | +| ↳ `category` | object | Category associated with the time entry | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| ↳ `sourceType` | string | Source of the time entry \(GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE\) | +| ↳ `status` | string | Approval status of the time entry \(NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED\) | +| ↳ `createdAt` | number | Creation timestamp in epoch milliseconds | +| ↳ `updatedAt` | number | Last-updated timestamp in epoch milliseconds | +| ↳ `createdBy` | object | User who created the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | User who last updated the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedBy` | object | User who submitted the time entry \(may be null even for approved/rejected entries\) | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `submittedAt` | number | Submission timestamp in epoch milliseconds | +| ↳ `approvedBy` | object | User who approved the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `approvedAt` | number | Approval timestamp in epoch milliseconds | +| ↳ `rejectedBy` | object | User who rejected the time entry | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `rejectedAt` | number | Rejection timestamp in epoch milliseconds | +| ↳ `deleted` | boolean | Whether the time entry is deleted | +| ↳ `costRate` | object | Hourly cost rate assigned to the user for this entry | +| ↳ `billRate` | object | Hourly rate billed to the customer for this entry | +| ↳ `fields` | array | Custom fields associated with the time entry | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field | +| ↳ `fieldValueLabel` | string | String representation of the field value | + +### `rocketlane_delete_time_entry` + +Permanently delete a Rocketlane time entry by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `timeEntryId` | number | Yes | ID of the time entry to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the time entry was deleted | +| `timeEntryId` | number | ID of the deleted time entry | + +### `rocketlane_list_time_entry_categories` + +List the time entry categories configured in Rocketlane + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Maximum number of categories per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response for fetching the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `categories` | array | List of time entry categories | +| ↳ `categoryId` | number | Unique identifier of the category | +| ↳ `categoryName` | string | Name of the category | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_create_time_off` + +Create a time-off for a team member in Rocketlane. Holidays and weekends within the date range are automatically excluded from the duration. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `userId` | number | No | User ID of the team member taking the time-off \(provide this or the user email\) | +| `userEmail` | string | No | Email of the team member taking the time-off \(provide this or the user ID\) | +| `startDate` | string | Yes | Time-off start date in YYYY-MM-DD format | +| `endDate` | string | Yes | Time-off end date in YYYY-MM-DD format \(on or after the start date\) | +| `type` | string | Yes | Type of the time-off: FULL_DAY, HALF_DAY, or CUSTOM | +| `durationInMinutes` | number | No | Duration in minutes per day; required when type is CUSTOM | +| `note` | string | No | Note or comment about the time-off | +| `notifyProjectOwners` | boolean | No | Notify project owners of projects the user is part of | +| `notifyUserIds` | array | No | User IDs of additional users to notify about the time-off | +| `notifyUserEmails` | array | No | Emails of additional users to notify about the time-off | +| `includeFields` | array | No | Optional fields to include in the response: note, notifyUsers | +| `includeAllFields` | boolean | No | Return all fields in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeOff` | object | The created time-off | +| ↳ `timeOffId` | number | Unique identifier of the time-off | +| ↳ `user` | object | The team member the time-off belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `note` | string | Note or comment about the time-off | +| ↳ `startDate` | string | Time-off start date \(YYYY-MM-DD\) | +| ↳ `endDate` | string | Time-off end date \(YYYY-MM-DD\) | +| ↳ `durationInMinutes` | number | Duration in minutes per day for the time-off interval | +| ↳ `type` | string | Type of the time-off \(FULL_DAY, HALF_DAY, or CUSTOM\) | +| ↳ `notifyUsers` | object | Users notified about the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Timestamp when the time-off was created \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | + +### `rocketlane_get_time_off` + +Retrieve a Rocketlane time-off by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `timeOffId` | number | Yes | Unique identifier of the time-off | +| `includeFields` | array | No | Optional fields to include in the response: note, notifyUsers | +| `includeAllFields` | boolean | No | Return all fields in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeOff` | object | The requested time-off | +| ↳ `timeOffId` | number | Unique identifier of the time-off | +| ↳ `user` | object | The team member the time-off belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `note` | string | Note or comment about the time-off | +| ↳ `startDate` | string | Time-off start date \(YYYY-MM-DD\) | +| ↳ `endDate` | string | Time-off end date \(YYYY-MM-DD\) | +| ↳ `durationInMinutes` | number | Duration in minutes per day for the time-off interval | +| ↳ `type` | string | Type of the time-off \(FULL_DAY, HALF_DAY, or CUSTOM\) | +| ↳ `notifyUsers` | object | Users notified about the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Timestamp when the time-off was created \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | + +### `rocketlane_list_time_offs` + +List time-offs in Rocketlane with optional date, type, and user filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Maximum number of time-offs per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | +| `includeFields` | array | No | Optional fields to include in the response: note, notifyUsers | +| `includeAllFields` | boolean | No | Return all fields in the response | +| `sortBy` | string | No | Field to sort by: startDate, endDate, or createdAt | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | Combine filters with AND \(all\) or OR \(any\); defaults to all | +| `startDateGt` | string | No | Return time-offs with start dates greater than this date \(YYYY-MM-DD\) | +| `startDateEq` | string | No | Return time-offs with start dates equal to this date \(YYYY-MM-DD\) | +| `startDateLt` | string | No | Return time-offs with start dates lesser than this date \(YYYY-MM-DD\) | +| `startDateGe` | string | No | Return time-offs with start dates greater than or equal to this date \(YYYY-MM-DD\) | +| `startDateLe` | string | No | Return time-offs with start dates lesser than or equal to this date \(YYYY-MM-DD\) | +| `endDateGt` | string | No | Return time-offs with end dates greater than this date \(YYYY-MM-DD\) | +| `endDateEq` | string | No | Return time-offs with end dates equal to this date \(YYYY-MM-DD\) | +| `endDateLt` | string | No | Return time-offs with end dates lesser than this date \(YYYY-MM-DD\) | +| `endDateGe` | string | No | Return time-offs with end dates greater than or equal to this date \(YYYY-MM-DD\) | +| `endDateLe` | string | No | Return time-offs with end dates lesser than or equal to this date \(YYYY-MM-DD\) | +| `typeEq` | string | No | Return time-offs matching this type: FULL_DAY, HALF_DAY, or CUSTOM | +| `typeOneOf` | string | No | Comma-separated time-off types to match any of \(FULL_DAY, HALF_DAY, CUSTOM\) | +| `typeNoneOf` | string | No | Comma-separated time-off types to match none of \(FULL_DAY, HALF_DAY, CUSTOM\) | +| `userIdEq` | string | No | Return time-offs that exactly match this user ID | +| `userIdOneOf` | string | No | Comma-separated user IDs to match any of | +| `userIdNoneOf` | string | No | Comma-separated user IDs to match none of | +| `emailIdEq` | string | No | Return time-offs that exactly match this user email | +| `emailIdOneOf` | string | No | Comma-separated user emails to match any of | +| `emailIdNoneOf` | string | No | Comma-separated user emails to match none of | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeOffs` | array | List of time-offs | +| ↳ `timeOffId` | number | Unique identifier of the time-off | +| ↳ `user` | object | The team member the time-off belongs to | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `note` | string | Note or comment about the time-off | +| ↳ `startDate` | string | Time-off start date \(YYYY-MM-DD\) | +| ↳ `endDate` | string | Time-off end date \(YYYY-MM-DD\) | +| ↳ `durationInMinutes` | number | Duration in minutes per day for the time-off interval | +| ↳ `type` | string | Type of the time-off \(FULL_DAY, HALF_DAY, or CUSTOM\) | +| ↳ `notifyUsers` | object | Users notified about the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `createdAt` | number | Timestamp when the time-off was created \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the time-off | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_delete_time_off` + +Permanently delete a Rocketlane time-off by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `timeOffId` | number | Yes | Unique identifier of the time-off to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the time-off was deleted | +| `timeOffId` | number | ID of the deleted time-off | + +### `rocketlane_get_user` + +Retrieve a Rocketlane user by their ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `userId` | number | Yes | ID of the user to retrieve | +| `includeFields` | string | No | Comma-separated extra fields to include: role, company, permission, holidayCalendar, capacityInMinutes, profilePictureUrl | +| `includeAllFields` | boolean | No | Whether to include all fields in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The requested user | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `email` | string | Email address of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `type` | string | Type of the user \(TEAM_MEMBER, PARTNER, CUSTOMER, or EXTERNAL_PARTNER\) | +| ↳ `status` | string | Status of the user \(INACTIVE, INVITED, ACTIVE, or PASSIVE\) | +| ↳ `role` | object | Role of the user | +| ↳ `roleId` | number | Unique identifier of the role | +| ↳ `roleName` | string | Name of the role | +| ↳ `company` | object | Company of the user | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `permission` | object | Permission of the user | +| ↳ `permissionId` | number | Unique identifier of the permission | +| ↳ `permissionName` | string | Name of the permission | +| ↳ `fields` | array | Custom user field values | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the custom user field | +| ↳ `fieldValue` | string | Value of the custom user field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `capacityInMinutes` | number | Capacity of the user in minutes | +| ↳ `holidayCalendar` | object | Holiday calendar of the user | +| ↳ `calenderId` | number | Unique identifier of the holiday calendar | +| ↳ `calenderName` | string | Name of the holiday calendar | +| ↳ `profilePictureUrl` | string | URL of the user's profile picture | +| ↳ `createdAt` | number | Timestamp when the user was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the user | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the user was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the user | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | + +### `rocketlane_list_users` + +List users in your Rocketlane account, with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Maximum number of users per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous request \(valid for 15 minutes\) | +| `includeFields` | string | No | Comma-separated extra fields to include: role, company, permission, holidayCalendar, capacityInMinutes, profilePictureUrl | +| `includeAllFields` | boolean | No | Whether to include all fields in the response | +| `sortBy` | string | No | Field to sort by: email, firstName, lastName, type, status, or capacityInMinutes | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | How to combine filters: all \(AND\) or any \(OR\); defaults to all | +| `firstNameEq` | string | No | Only include users whose first name exactly matches this value | +| `firstNameCn` | string | No | Only include users whose first name contains this value | +| `firstNameNc` | string | No | Exclude users whose first name contains this value | +| `lastNameEq` | string | No | Only include users whose last name exactly matches this value | +| `lastNameCn` | string | No | Only include users whose last name contains this value | +| `lastNameNc` | string | No | Exclude users whose last name contains this value | +| `emailEq` | string | No | Only include users whose email exactly matches this value | +| `emailCn` | string | No | Only include users whose email contains this value | +| `emailNc` | string | No | Exclude users whose email contains this value | +| `statusEq` | string | No | Only include users with this status: INACTIVE, INVITED, ACTIVE, or PASSIVE | +| `statusOneOf` | string | No | Comma-separated statuses; only include users matching one of them \(INACTIVE, INVITED, ACTIVE, PASSIVE\) | +| `statusNoneOf` | string | No | Comma-separated statuses; exclude users matching any of them \(INACTIVE, INVITED, ACTIVE, PASSIVE\) | +| `typeEq` | string | No | Only include users of this type: TEAM_MEMBER, PARTNER, CUSTOMER, or EXTERNAL_PARTNER | +| `typeOneOf` | string | No | Comma-separated types; only include users matching one of them \(TEAM_MEMBER, PARTNER, CUSTOMER, EXTERNAL_PARTNER\) | +| `roleIdEq` | string | No | Only include users with this role ID | +| `roleIdOneOf` | string | No | Comma-separated role IDs; only include users matching one of them | +| `roleIdNoneOf` | string | No | Comma-separated role IDs; exclude users matching any of them | +| `permissionIdEq` | string | No | Only include users with this permission ID | +| `permissionIdOneOf` | string | No | Comma-separated permission IDs; only include users matching one of them | +| `permissionIdNoneOf` | string | No | Comma-separated permission IDs; exclude users matching any of them | +| `capacityInMinutesEq` | number | No | Only include users whose capacity in minutes equals this value | +| `capacityInMinutesGt` | number | No | Only include users whose capacity in minutes is greater than this value | +| `capacityInMinutesGe` | number | No | Only include users whose capacity in minutes is greater than or equal to this value | +| `capacityInMinutesLt` | number | No | Only include users whose capacity in minutes is less than this value | +| `capacityInMinutesLe` | number | No | Only include users whose capacity in minutes is less than or equal to this value | +| `createdAtGt` | number | No | Only include users created after this time \(epoch millis\) | +| `createdAtEq` | number | No | Only include users created at exactly this time \(epoch millis\) | +| `createdAtLt` | number | No | Only include users created before this time \(epoch millis\) | +| `createdAtGe` | number | No | Only include users created at or after this time \(epoch millis\) | +| `createdAtLe` | number | No | Only include users created at or before this time \(epoch millis\) | +| `updatedAtGt` | number | No | Only include users updated after this time \(epoch millis\) | +| `updatedAtEq` | number | No | Only include users updated at exactly this time \(epoch millis\) | +| `updatedAtLt` | number | No | Only include users updated before this time \(epoch millis\) | +| `updatedAtGe` | number | No | Only include users updated at or after this time \(epoch millis\) | +| `updatedAtLe` | number | No | Only include users updated at or before this time \(epoch millis\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of users | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `email` | string | Email address of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `type` | string | Type of the user \(TEAM_MEMBER, PARTNER, CUSTOMER, or EXTERNAL_PARTNER\) | +| ↳ `status` | string | Status of the user \(INACTIVE, INVITED, ACTIVE, or PASSIVE\) | +| ↳ `role` | object | Role of the user | +| ↳ `roleId` | number | Unique identifier of the role | +| ↳ `roleName` | string | Name of the role | +| ↳ `company` | object | Company of the user | +| ↳ `companyId` | number | Unique identifier of the company | +| ↳ `companyName` | string | Name of the company | +| ↳ `permission` | object | Permission of the user | +| ↳ `permissionId` | number | Unique identifier of the permission | +| ↳ `permissionName` | string | Name of the permission | +| ↳ `fields` | array | Custom user field values | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Name of the custom user field | +| ↳ `fieldValue` | string | Value of the custom user field | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `capacityInMinutes` | number | Capacity of the user in minutes | +| ↳ `holidayCalendar` | object | Holiday calendar of the user | +| ↳ `calenderId` | number | Unique identifier of the holiday calendar | +| ↳ `calenderName` | string | Name of the holiday calendar | +| ↳ `profilePictureUrl` | string | URL of the user's profile picture | +| ↳ `createdAt` | number | Timestamp when the user was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the user | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the user was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the user | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| `pagination` | object | Pagination details | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_create_space` + +Create a new space in a Rocketlane project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | ID of the project the space belongs to | +| `spaceName` | string | Yes | Name of the space | +| `private` | boolean | No | Whether the space is private or shared \(defaults to false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `space` | object | The created space | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `project` | object | Project the space belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `createdAt` | number | Timestamp when the space was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space is private or shared | + +### `rocketlane_get_space` + +Retrieve a Rocketlane space by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceId` | number | Yes | ID of the space to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `space` | object | The requested space | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `project` | object | Project the space belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `createdAt` | number | Timestamp when the space was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space is private or shared | + +### `rocketlane_list_spaces` + +List spaces in a Rocketlane project, with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | ID of the project whose spaces to list | +| `pageSize` | number | No | Maximum number of spaces per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous request \(valid for 15 minutes\) | +| `sortBy` | string | No | Field to sort by \(spaceName\) | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | How to combine filters: all \(AND\) or any \(OR\); defaults to all | +| `spaceNameEq` | string | No | Only include spaces whose name exactly matches this value | +| `spaceNameCn` | string | No | Only include spaces whose name contains this value | +| `spaceNameNc` | string | No | Exclude spaces whose name contains this value | +| `createdAtGt` | number | No | Only include spaces created after this time \(epoch millis\) | +| `createdAtEq` | number | No | Only include spaces created at exactly this time \(epoch millis\) | +| `createdAtLt` | number | No | Only include spaces created before this time \(epoch millis\) | +| `createdAtGe` | number | No | Only include spaces created at or after this time \(epoch millis\) | +| `createdAtLe` | number | No | Only include spaces created at or before this time \(epoch millis\) | +| `updatedAtGt` | number | No | Only include spaces updated after this time \(epoch millis\) | +| `updatedAtEq` | number | No | Only include spaces updated at exactly this time \(epoch millis\) | +| `updatedAtLt` | number | No | Only include spaces updated before this time \(epoch millis\) | +| `updatedAtGe` | number | No | Only include spaces updated at or after this time \(epoch millis\) | +| `updatedAtLe` | number | No | Only include spaces updated at or before this time \(epoch millis\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaces` | array | List of spaces | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `project` | object | Project the space belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `createdAt` | number | Timestamp when the space was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space is private or shared | +| `pagination` | object | Pagination details | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_space` + +Update a Rocketlane space by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceId` | number | Yes | ID of the space to update | +| `spaceName` | string | No | New name of the space | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `space` | object | The updated space | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `project` | object | Project the space belongs to | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `createdAt` | number | Timestamp when the space was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space is private or shared | + +### `rocketlane_delete_space` + +Permanently delete a Rocketlane space by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceId` | number | Yes | ID of the space to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the space was deleted | +| `spaceId` | number | ID of the deleted space | + +### `rocketlane_create_space_document` + +Create a new space document in a Rocketlane space + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceId` | number | Yes | ID of the space the document belongs to | +| `spaceDocumentType` | string | Yes | Type of the space document: ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT | +| `spaceDocumentName` | string | No | Name of the space document \(defaults to Untitled\) | +| `url` | string | No | URL to embed in the space document \(for embedded documents\) | +| `templateId` | number | No | ID of the document template to create the document from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaceDocument` | object | The created space document | +| ↳ `spaceDocumentId` | number | Unique identifier of the space document | +| ↳ `spaceDocumentName` | string | Name of the space document | +| ↳ `space` | object | Space the document belongs to | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `spaceDocumentType` | string | Type of the space document \(ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT\) | +| ↳ `url` | string | URL embedded in the space document | +| ↳ `source` | object | Document template the space document was created from | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `createdAt` | number | Timestamp when the space document was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space document was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space document is private or shared | + +### `rocketlane_get_space_document` + +Retrieve a Rocketlane space document by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceDocumentId` | number | Yes | ID of the space document to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaceDocument` | object | The requested space document | +| ↳ `spaceDocumentId` | number | Unique identifier of the space document | +| ↳ `spaceDocumentName` | string | Name of the space document | +| ↳ `space` | object | Space the document belongs to | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `spaceDocumentType` | string | Type of the space document \(ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT\) | +| ↳ `url` | string | URL embedded in the space document | +| ↳ `source` | object | Document template the space document was created from | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `createdAt` | number | Timestamp when the space document was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space document was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space document is private or shared | + +### `rocketlane_list_space_documents` + +List space documents in a Rocketlane project, with optional filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `projectId` | number | Yes | ID of the project whose space documents to list | +| `pageSize` | number | No | Maximum number of space documents per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous request \(valid for 15 minutes\) | +| `sortBy` | string | No | Field to sort by \(spaceTabName\) | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | How to combine filters: all \(AND\) or any \(OR\); defaults to all | +| `spaceDocumentNameEq` | string | No | Only include space documents whose name exactly matches this value | +| `spaceDocumentNameCn` | string | No | Only include space documents whose name contains this value | +| `spaceDocumentNameNc` | string | No | Exclude space documents whose name contains this value | +| `spaceIdEq` | number | No | Only include space documents in the space with this ID | +| `createdAtGt` | number | No | Only include space documents created after this time \(epoch millis\) | +| `createdAtEq` | number | No | Only include space documents created at exactly this time \(epoch millis\) | +| `createdAtLt` | number | No | Only include space documents created before this time \(epoch millis\) | +| `createdAtGe` | number | No | Only include space documents created at or after this time \(epoch millis\) | +| `createdAtLe` | number | No | Only include space documents created at or before this time \(epoch millis\) | +| `updatedAtGt` | number | No | Only include space documents updated after this time \(epoch millis\) | +| `updatedAtEq` | number | No | Only include space documents updated at exactly this time \(epoch millis\) | +| `updatedAtLt` | number | No | Only include space documents updated before this time \(epoch millis\) | +| `updatedAtGe` | number | No | Only include space documents updated at or after this time \(epoch millis\) | +| `updatedAtLe` | number | No | Only include space documents updated at or before this time \(epoch millis\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaceDocuments` | array | List of space documents | +| ↳ `spaceDocumentId` | number | Unique identifier of the space document | +| ↳ `spaceDocumentName` | string | Name of the space document | +| ↳ `space` | object | Space the document belongs to | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `spaceDocumentType` | string | Type of the space document \(ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT\) | +| ↳ `url` | string | URL embedded in the space document | +| ↳ `source` | object | Document template the space document was created from | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `createdAt` | number | Timestamp when the space document was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space document was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space document is private or shared | +| `pagination` | object | Pagination details | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_update_space_document` + +Update a Rocketlane space document by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceDocumentId` | number | Yes | ID of the space document to update | +| `spaceDocumentName` | string | No | New name of the space document | +| `url` | string | No | New URL to embed in the space document \(for embedded documents\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `spaceDocument` | object | The updated space document | +| ↳ `spaceDocumentId` | number | Unique identifier of the space document | +| ↳ `spaceDocumentName` | string | Name of the space document | +| ↳ `space` | object | Space the document belongs to | +| ↳ `spaceId` | number | Unique identifier of the space | +| ↳ `spaceName` | string | Name of the space | +| ↳ `spaceDocumentType` | string | Type of the space document \(ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT\) | +| ↳ `url` | string | URL embedded in the space document | +| ↳ `source` | object | Document template the space document was created from | +| ↳ `templateId` | number | Unique identifier of the template | +| ↳ `templateName` | string | Name of the template | +| ↳ `createdAt` | number | Timestamp when the space document was created \(epoch millis\) | +| ↳ `createdBy` | object | Team member who created the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedAt` | number | Timestamp when the space document was last updated \(epoch millis\) | +| ↳ `updatedBy` | object | Team member who last updated the space document | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `private` | boolean | Whether the space document is private or shared | + +### `rocketlane_delete_space_document` + +Permanently delete a Rocketlane space document by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `spaceDocumentId` | number | Yes | ID of the space document to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the space document was deleted | +| `spaceDocumentId` | number | ID of the deleted space document | + +### `rocketlane_list_resource_allocations` + +List resource allocations in Rocketlane within a date range, with optional member, project, and placeholder filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `startDate` | string | Yes | Return allocations that start on or after this date \(YYYY-MM-DD\) | +| `endDate` | string | Yes | Return allocations that end on or before this date \(YYYY-MM-DD\) | +| `pageSize` | number | No | Maximum number of allocations per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | +| `includeFields` | array | No | Optional fields to include in the response: member, task, placeholder, duration | +| `includeAllFields` | boolean | No | Return all fields in the response | +| `sortBy` | string | No | Field to sort by: startDate, endDate, allocationType, or allocationFor | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | Combine filters with AND \(all\) or OR \(any\); defaults to all | +| `memberIdEq` | string | No | Return allocations that exactly match this member ID | +| `memberIdOneOf` | string | No | Comma-separated member IDs to match any of | +| `memberIdNoneOf` | string | No | Comma-separated member IDs to exclude | +| `projectIdEq` | string | No | Return allocations that exactly match this project ID | +| `projectIdOneOf` | string | No | Comma-separated project IDs to match any of | +| `projectIdNoneOf` | string | No | Comma-separated project IDs to exclude | +| `placeholderIdEq` | string | No | Return allocations that exactly match this placeholder ID | +| `placeholderIdOneOf` | string | No | Comma-separated placeholder IDs to match any of | +| `placeholderIdNoneOf` | string | No | Comma-separated placeholder IDs to exclude | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `allocations` | array | List of resource allocations | +| ↳ `startDate` | string | Allocation start date \(YYYY-MM-DD\) | +| ↳ `endDate` | string | Allocation end date \(YYYY-MM-DD\) | +| ↳ `secondsPerDay` | number | Allocated seconds per day | +| ↳ `minutesPerDay` | number | Allocated minutes per day | +| ↳ `hoursPerDay` | number | Allocated hours per day | +| ↳ `duration` | object | Total allocation duration between the start and end dates | +| ↳ `daysConsider` | number | Number of week days considered for the duration computation | +| ↳ `seconds` | number | Total allocation seconds | +| ↳ `minutes` | number | Total allocation minutes | +| ↳ `hours` | number | Total allocation hours | +| ↳ `allocationType` | string | Type of allocation \(SOFT or HARD\) | +| ↳ `allocationFor` | string | Who the allocation is for \(TEAM_MEMBER or PLACEHOLDER\) | +| ↳ `project` | object | The project associated with the allocation | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `tasks` | array | Tasks associated with the allocation | +| ↳ `taskId` | number | Unique identifier of the task | +| ↳ `taskName` | string | Name of the task | +| ↳ `member` | object | The team member allocated when allocationFor is TEAM_MEMBER | +| ↳ `placeholder` | object | The placeholder allocated when allocationFor is PLACEHOLDER | +| ↳ `createdAt` | number | Timestamp when the allocation was created \(epoch milliseconds\) | +| ↳ `updatedAt` | number | Timestamp when the allocation was last updated \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the allocation | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | The team member who last updated the allocation | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_get_invoice` + +Retrieve a Rocketlane invoice by its ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `invoiceId` | number | Yes | Unique identifier of the invoice | +| `includeFields` | array | No | Optional fields to include in the response: notes, attachments | +| `includeAllFields` | boolean | No | Return all fields in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invoice` | object | The requested invoice | +| ↳ `invoiceId` | number | Unique identifier of the invoice | +| ↳ `invoiceNumber` | string | Invoice number assigned to this invoice | +| ↳ `dateOfIssue` | string | Date when the invoice was issued \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Due date for the invoice payment \(YYYY-MM-DD\) | +| ↳ `currency` | string | Currency of the invoice amount \(e.g. USD\) | +| ↳ `status` | string | Current status of the invoice | +| ↳ `amount` | number | Total amount of the invoice including tax | +| ↳ `tax` | number | Tax amount applied to the invoice | +| ↳ `subTotal` | number | Total amount of the invoice excluding tax | +| ↳ `amountOutstanding` | number | Balance amount remaining to be paid | +| ↳ `amountPaid` | number | Total amount paid for this invoice | +| ↳ `amountWrittenOff` | number | Total amount written off for this invoice | +| ↳ `notes` | string | Notes or additional information about the invoice | +| ↳ `createdAt` | number | Timestamp when the invoice was created \(epoch milliseconds\) | +| ↳ `updatedAt` | number | Timestamp when the invoice was last updated \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the invoice | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | The team member who last updated the invoice | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `company` | object | Customer company details for the invoice | +| ↳ `companyId` | number | Unique identifier of the customer company | +| ↳ `companyName` | string | Name of the customer company | +| ↳ `companyUrl` | string | URL of the customer company website | +| ↳ `projects` | array | Projects mapped to this invoice | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `fields` | array | Custom invoice fields with their values | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array depending on field type\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `attachments` | array | Attachments associated with the invoice | +| ↳ `attachmentId` | number | Unique identifier of the attachment | +| ↳ `attachmentName` | string | Name of the attachment | +| ↳ `createdAt` | number | Timestamp when the attachment was created \(epoch milliseconds\) | +| ↳ `location` | string | URL of the attachment | +| ↳ `thumbLocation` | string | Thumbnail URL of the attachment | +| ↳ `visibility` | boolean | Visibility of the attachment | + +### `rocketlane_list_invoices` + +Search invoices in Rocketlane with date, amount, company, invoice-number, and status filters, sorting, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `pageSize` | number | No | Maximum number of invoices per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | +| `includeFields` | array | No | Optional fields to include in the response: notes, attachments | +| `includeAllFields` | boolean | No | Return all fields in the response | +| `sortBy` | string | No | Field to sort by: createdAt or invoiceNumber | +| `sortOrder` | string | No | Sort order: ASC or DESC \(defaults to DESC\) | +| `match` | string | No | Combine filters with AND \(all\) or OR \(any\); defaults to all | +| `dateOfIssueEq` | string | No | Filter by date of issue equal to this date \(YYYY-MM-DD\) | +| `dateOfIssueGt` | string | No | Filter by date of issue greater than this date \(YYYY-MM-DD\) | +| `dateOfIssueGe` | string | No | Filter by date of issue greater than or equal to this date \(YYYY-MM-DD\) | +| `dateOfIssueLt` | string | No | Filter by date of issue less than this date \(YYYY-MM-DD\) | +| `dateOfIssueLe` | string | No | Filter by date of issue less than or equal to this date \(YYYY-MM-DD\) | +| `dueDateEq` | string | No | Filter by due date equal to this date \(YYYY-MM-DD\) | +| `dueDateGt` | string | No | Filter by due date greater than this date \(YYYY-MM-DD\) | +| `dueDateGe` | string | No | Filter by due date greater than or equal to this date \(YYYY-MM-DD\) | +| `dueDateLt` | string | No | Filter by due date less than this date \(YYYY-MM-DD\) | +| `dueDateLe` | string | No | Filter by due date less than or equal to this date \(YYYY-MM-DD\) | +| `amountEq` | number | No | Filter by total amount equal to this value | +| `amountGt` | number | No | Filter by total amount greater than this value | +| `amountGe` | number | No | Filter by total amount greater than or equal to this value | +| `amountLt` | number | No | Filter by total amount less than this value | +| `amountLe` | number | No | Filter by total amount less than or equal to this value | +| `amountOutstandingEq` | number | No | Filter by amount outstanding equal to this value | +| `amountOutstandingGt` | number | No | Filter by amount outstanding greater than this value | +| `amountOutstandingGe` | number | No | Filter by amount outstanding greater than or equal to this value | +| `amountOutstandingLt` | number | No | Filter by amount outstanding less than this value | +| `amountOutstandingLe` | number | No | Filter by amount outstanding less than or equal to this value | +| `amountPaidEq` | number | No | Filter by amount paid equal to this value | +| `amountPaidGt` | number | No | Filter by amount paid greater than this value | +| `amountPaidGe` | number | No | Filter by amount paid greater than or equal to this value | +| `amountPaidLt` | number | No | Filter by amount paid less than this value | +| `amountPaidLe` | number | No | Filter by amount paid less than or equal to this value | +| `amountWrittenOffEq` | number | No | Filter by amount written off equal to this value | +| `amountWrittenOffGt` | number | No | Filter by amount written off greater than this value | +| `amountWrittenOffGe` | number | No | Filter by amount written off greater than or equal to this value | +| `amountWrittenOffLt` | number | No | Filter by amount written off less than this value | +| `amountWrittenOffLe` | number | No | Filter by amount written off less than or equal to this value | +| `createdAtEq` | number | No | Filter by created timestamp equal to this value \(epoch milliseconds\) | +| `createdAtGt` | number | No | Filter by created timestamp greater than this value \(epoch milliseconds\) | +| `createdAtGe` | number | No | Filter by created timestamp greater than or equal to this value \(epoch milliseconds\) | +| `createdAtLt` | number | No | Filter by created timestamp less than this value \(epoch milliseconds\) | +| `createdAtLe` | number | No | Filter by created timestamp less than or equal to this value \(epoch milliseconds\) | +| `companyIdEq` | string | No | Return invoices that exactly match this customer company ID | +| `companyIdOneOf` | string | No | Comma-separated customer company IDs to match any of | +| `companyIdNoneOf` | string | No | Comma-separated customer company IDs to match none of | +| `invoiceNumberEq` | string | No | Return invoices whose invoice number equals this value | +| `invoiceNumberCn` | string | No | Return invoices whose invoice number contains this text | +| `invoiceNumberNc` | string | No | Return invoices whose invoice number does not contain this text | +| `statusEq` | string | No | Return invoices that equal this status \(e.g. DRAFT\) | +| `statusOneOf` | string | No | Comma-separated statuses to match any of \(e.g. DRAFT,PAID\) | +| `statusNoneOf` | string | No | Comma-separated statuses to match none of \(e.g. DRAFT,PAID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invoices` | array | List of invoices | +| ↳ `invoiceId` | number | Unique identifier of the invoice | +| ↳ `invoiceNumber` | string | Invoice number assigned to this invoice | +| ↳ `dateOfIssue` | string | Date when the invoice was issued \(YYYY-MM-DD\) | +| ↳ `dueDate` | string | Due date for the invoice payment \(YYYY-MM-DD\) | +| ↳ `currency` | string | Currency of the invoice amount \(e.g. USD\) | +| ↳ `status` | string | Current status of the invoice | +| ↳ `amount` | number | Total amount of the invoice including tax | +| ↳ `tax` | number | Tax amount applied to the invoice | +| ↳ `subTotal` | number | Total amount of the invoice excluding tax | +| ↳ `amountOutstanding` | number | Balance amount remaining to be paid | +| ↳ `amountPaid` | number | Total amount paid for this invoice | +| ↳ `amountWrittenOff` | number | Total amount written off for this invoice | +| ↳ `notes` | string | Notes or additional information about the invoice | +| ↳ `createdAt` | number | Timestamp when the invoice was created \(epoch milliseconds\) | +| ↳ `updatedAt` | number | Timestamp when the invoice was last updated \(epoch milliseconds\) | +| ↳ `createdBy` | object | The team member who created the invoice | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `updatedBy` | object | The team member who last updated the invoice | +| ↳ `userId` | number | Unique identifier of the user | +| ↳ `firstName` | string | First name of the user | +| ↳ `lastName` | string | Last name of the user | +| ↳ `emailId` | string | Email address of the user | +| ↳ `company` | object | Customer company details for the invoice | +| ↳ `companyId` | number | Unique identifier of the customer company | +| ↳ `companyName` | string | Name of the customer company | +| ↳ `companyUrl` | string | URL of the customer company website | +| ↳ `projects` | array | Projects mapped to this invoice | +| ↳ `projectId` | number | Unique identifier of the project | +| ↳ `projectName` | string | Name of the project | +| ↳ `fields` | array | Custom invoice fields with their values | +| ↳ `fieldId` | number | Unique identifier of the field | +| ↳ `fieldLabel` | string | Label of the field | +| ↳ `fieldValue` | json | Value of the field \(string, number, or array depending on field type\) | +| ↳ `fieldValueLabel` | string | String representation of the field value | +| ↳ `attachments` | array | Attachments associated with the invoice | +| ↳ `attachmentId` | number | Unique identifier of the attachment | +| ↳ `attachmentName` | string | Name of the attachment | +| ↳ `createdAt` | number | Timestamp when the attachment was created \(epoch milliseconds\) | +| ↳ `location` | string | URL of the attachment | +| ↳ `thumbLocation` | string | Thumbnail URL of the attachment | +| ↳ `visibility` | boolean | Visibility of the attachment | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_get_invoice_line_items` + +List line items of a Rocketlane invoice + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `invoiceId` | number | Yes | Unique identifier of the invoice | +| `pageSize` | number | No | Maximum number of line items per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lineItems` | array | List of invoice line items | +| ↳ `invoiceLineItemId` | number | Unique identifier of the invoice line item | +| ↳ `description` | string | Description of the line item or service provided | +| ↳ `quantity` | number | Quantity of the item or service | +| ↳ `unitPrice` | number | Unit price for the item or service | +| ↳ `amount` | number | Total amount for this line item \(quantity times unit price\) | +| ↳ `sourceId` | number | Unique identifier of the source entity \(e.g. project ID\) | +| ↳ `sourceType` | string | Type of source entity this line item is associated with \(e.g. PROJECT\) | +| ↳ `taxCode` | object | Tax code information for this line item | +| ↳ `taxCodeId` | number | Unique identifier of the tax code | +| ↳ `taxCodeName` | string | Name of the tax code | +| ↳ `taxCodeRate` | number | Tax rate percentage for the tax code | +| ↳ `taxCodeAmount` | number | Tax amount calculated for this tax code | +| ↳ `taxComponents` | array | Tax components that make up the tax code | +| ↳ `taxComponentId` | number | Unique identifier of the tax component | +| ↳ `taxComponentName` | string | Name of the tax component | +| ↳ `taxComponentRate` | number | Tax rate percentage for the tax component | +| ↳ `taxComponentAmount` | number | Tax amount calculated for this tax component | +| ↳ `taxComponentType` | string | Type of the tax component \(e.g. GST, VAT\) | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + +### `rocketlane_get_invoice_payments` + +List payments recorded against a Rocketlane invoice + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Rocketlane API key | +| `invoiceId` | number | Yes | Unique identifier of the invoice | +| `pageSize` | number | No | Maximum number of payments per page \(defaults to 100\) | +| `pageToken` | string | No | Page token from a previous response \(valid for 15 minutes\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `payments` | array | List of payments recorded against the invoice | +| ↳ `paymentId` | number | Unique identifier of the payment record | +| ↳ `paymentRecordType` | string | Type of the payment record \(PAID or WRITE_OFF\) | +| ↳ `currency` | string | Currency of the payment amount \(e.g. USD\) | +| ↳ `paymentDate` | string | Date when the payment was made \(YYYY-MM-DD\) | +| ↳ `amount` | number | Amount of the payment | +| ↳ `notes` | string | Additional notes or comments regarding the payment | +| `pagination` | object | Pagination details for the result set | +| ↳ `pageSize` | number | Page size used for the current request | +| ↳ `hasMore` | boolean | Whether more results are available | +| ↳ `totalRecordCount` | number | Total number of records matching the request | +| ↳ `nextPageToken` | string | Token for fetching the next page \(valid for 15 minutes\) | + + diff --git a/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx new file mode 100644 index 00000000000..5c18f72f256 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx @@ -0,0 +1,174 @@ +--- +title: Salesforce Integration Users +description: Set up a Salesforce External Client App with the Client Credentials Flow and a dedicated integration user to use Salesforce in Sim workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Salesforce's OAuth 2.0 Client Credentials Flow lets your workflows authenticate to Salesforce as a dedicated **integration user** instead of through a person's OAuth login. An admin creates an External Client App once, enables the flow, and picks the "Run As" user whose permissions every API call executes with — no user consent to expire, and data access that's governed entirely by that user's profile and permission sets. + +This is the recommended way to use Salesforce in production workflows: nothing depends on a person staying logged in, and what the credential can touch is exactly what the integration user can touch. + +## Prerequisites + +You need a Salesforce **admin** to create the External Client App and the integration user. Every API call Sim makes runs with the integration user's permissions, so plan that user's profile and permission sets deliberately. + +## Setting Up the External Client App + +### 1. Create a Dedicated Integration User + + + + In Salesforce, go to **Setup** → **Users** → **New User**. Set **License** to **Salesforce Integration** (the free API-only license) and **Profile** to **Minimum Access - API Only Integrations** + + {/* TODO(screenshot): New User form with the Salesforce Integration license and API-only profile selected */} + + + Create a permission set backed by the **Salesforce API Integration** permission set license, grant it the object and field permissions your workflows need (least privilege), and assign it to the user + + + + +If your org doesn't have Salesforce Integration licenses, any standard user with the **API Enabled** permission works too — but a dedicated API-only user keeps the credential's access explicit and auditable. + + +### 2. Create the External Client App + +External Client Apps are Salesforce's current-generation connected apps and the default way to create new OAuth apps. + + + + Go to **Setup** → **External Client App Manager** → **New External Client App**, give the app a name and contact email, and keep it local to your org (not packaged for distribution) + + {/* TODO(screenshot): New External Client App form with the basic details filled in */} + + + In the OAuth settings section, enable OAuth (exact labels vary by Salesforce release) + + + Enter any placeholder **Callback URL** (e.g. `https://login.salesforce.com/services/oauth2/callback`) — it's required by the form but unused by this flow + + + Add the OAuth scopes **Manage user data via APIs (api)** and **Access unique user identifiers (openid)** — `api` is required for the flow, and `openid` lets Sim look up the integration user's name via the userinfo endpoint (the instance URL comes back in the token response itself) + + + Enable the **Client Credentials Flow** in the OAuth settings, acknowledge the warning, and create the app + + {/* TODO(screenshot): OAuth settings with Enable Client Credentials Flow checked */} + + + +### 3. Configure the Run As User + + + + Open your app in **External Client App Manager** and edit its **Policies** + + + Under **OAuth Policies** → **Client Credentials Flow**, check **Enable Client Credentials Flow** and set **Run As** to the integration user from step 1, then save + + {/* TODO(screenshot): OAuth Policies with the Run As integration user set under Client Credentials Flow */} + + + +Every API call Sim makes executes with this user's permissions. + +### 4. Copy the Consumer Key and Secret + +Open the app's **Settings** → **OAuth Settings** and click **Consumer Key and Secret** (Salesforce prompts for identity verification). Copy the **Consumer Key** and **Consumer Secret**. + +{/* TODO(screenshot): OAuth Settings page showing the Consumer Key and Consumer Secret */} + + +The Consumer Secret plus the Run As configuration is full API access as the integration user. Treat both values like passwords — do not commit them to source control or share them publicly. Sim encrypts them at rest. + + +### Using an Existing Connected App (Legacy) + + +Creating **new** Connected Apps is blocked by default: since Summer '25 new orgs ship with Connected App creation disabled, and Spring '26 turned it off across all orgs — re-enabling it requires a request to Salesforce Support. Use an External Client App for new setups. + + +If you already have a classic Connected App, it keeps working and the credential fields are identical — the token endpoint and Sim configuration don't change. Configure it the classic way: + + + + In **Setup** → **App Manager**, confirm the app has **Enable OAuth Settings**, the **Manage user data via APIs (api)** and **Access unique user identifiers (openid)** scopes, and **Enable Client Credentials Flow** checked + + + From **App Manager**, open the app's **Manage** page, click **Edit Policies**, and set **Run As** under **Client Credentials Flow** to your integration user. Permitted Users policies don't apply to the Client Credentials Flow's execution user, so no pre-authorization is needed + + + Open the app with **View** and click **Manage Consumer Details** to copy the **Consumer Key** and **Consumer Secret** + + + +### 5. Find Your My Domain Host + +Go to **Setup** and search for **My Domain**. The host is required — Salesforce rejects the Client Credentials Flow at `login.salesforce.com` and `test.salesforce.com`. Depending on your org type it looks like: + +- **Production:** `yourorg.my.salesforce.com` +- **Sandbox:** `yourorg--sandboxname.sandbox.my.salesforce.com` +- **Developer Edition:** `yourorg-dev-ed.develop.my.salesforce.com` + +Sim also accepts other partitioned My Domain hosts (`scratch`, `demo`, `patch`, `trailblaze`, `free`). Government and military domains (`*.my.salesforce.mil`) aren't currently supported. + +## Permissions Instead of Scopes + +There's no scope picking beyond the **api** and **openid** scopes on the app — what the credential can actually do is the integration user's profile plus permission sets. In particular: + +- Object and field access for every object your workflows read or write, plus **API Enabled** +- **Customize Application** for tools that manage custom fields and custom objects (Tooling API) — a Minimum Access API-only user passes validation but fails these specific tools without it +- **Run Reports** and folder access for report and dashboard tools + +A permissions gap surfaces at run time as a Salesforce API error; fix it on the integration user's permission sets — no changes are needed in Sim. + +## Adding the Service Account to Sim + + + + Open **Integrations** from your workspace sidebar + + + Search for "Salesforce" and open it, then click **Add to Sim** and choose **Add integration user app** + + {/* TODO(screenshot): Salesforce integration page with the Add integration user app connect option */} + + + In the **Add Salesforce integration user app** dialog, paste the **Consumer key**, **Consumer secret**, and **My Domain host** (e.g. `yourorg.my.salesforce.com`), and optionally set a display name and description + + {/* TODO(screenshot): Add Salesforce integration user app dialog with all three fields filled in */} + + + Click **Add integration user app**. Sim verifies the credentials by minting a real access token against your My Domain host. A host that doesn't resolve gets its own error message; a bad consumer key or secret and a flow that isn't fully configured both surface as a general authentication error — re-check all three values and the app's Client Credentials Flow policies. + + + +## Using the Service Account in Workflows + +Add a Salesforce block to your workflow. In the credential dropdown, your Salesforce service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Salesforce block in a workflow with the Salesforce service account selected as the credential */} + +The block calls your org's REST API with a freshly minted access token — the same requests as the OAuth flow, so every Salesforce tool works, subject to the integration user's permissions. + +## Token Behavior + +Access tokens from this flow have no fixed lifetime in the response — an opaque token stays valid until the Run As user's session times out (2 hours by default; configurable from 15 minutes to 24 hours in Session Settings). There is no refresh token; Sim mints a new token whenever one is needed, so session timeouts are invisible to your workflows. + + +Deactivating or freezing the Run As user stops all token minting with an `invalid_grant` error, halting every workflow that uses the credential. Password policies that expire the user's API access have the same effect. Treat the integration user as production infrastructure. + + + diff --git a/apps/docs/content/docs/en/integrations/zoom-service-account.mdx b/apps/docs/content/docs/en/integrations/zoom-service-account.mdx new file mode 100644 index 00000000000..163a1ec9cb3 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/zoom-service-account.mdx @@ -0,0 +1,150 @@ +--- +title: Zoom Server-to-Server OAuth Apps +description: Set up a Zoom Server-to-Server OAuth app so your workflows can call Zoom without a personal OAuth login +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +Zoom Server-to-Server OAuth apps let your workflows authenticate to Zoom at the account level instead of through a person's OAuth login. A Zoom admin creates the app once, grants it exactly the scopes it needs, and Sim mints short-lived access tokens from the app's credentials on demand — no user consent to expire, and permissions that are auditable from the Zoom Marketplace. + +This is the recommended way to use Zoom in production workflows: the credentials don't depend on any user staying logged in, the granted scopes are explicit, and tokens are minted fresh whenever a workflow runs. + +## Prerequisites + +You need a Zoom **admin** whose role has permission to view and edit Server-to-Server OAuth apps, plus permission for the scopes the app will use. The **Develop** option in the Zoom Marketplace only appears for accounts with this permission. + + +Scope selection is capped by the creating admin's own role permissions, and Zoom automatically removes admin-level scopes from the app if that admin's role is later downgraded — workflows then start failing with scope errors even though nothing changed in Sim. Create the app from an admin account you expect to keep. + + +## Setting Up the Server-to-Server OAuth App + +### 1. Create the App + + + + Sign in at [marketplace.zoom.us](https://marketplace.zoom.us) as a Zoom admin, click **Develop** → **Build an app**, select **Server-to-Server OAuth app**, and click **Create**. Give it a name (e.g. `Sim`) + + {/* TODO(screenshot): Zoom Marketplace Build an app dialog with Server-to-Server OAuth app selected */} + + + On the **App Credentials** page, copy the **Account ID**, **Client ID**, and **Client secret** — these are the three values you'll paste into Sim + + {/* TODO(screenshot): App Credentials page showing Account ID, Client ID, and Client secret */} + + + Use the Account ID from this **App Credentials** page — it's an opaque string, not a number. The numeric account number shown in your Zoom web-portal profile is a different value, and pasting it is a documented common mistake that produces "bad request" errors when minting tokens. + + + + In the **Information** section, fill in the required short description, company name, and developer name and email — the app can't be activated without them + + + In **Scopes**, click **Add Scopes** and add the admin-level granular scopes your workflows need (see the list below) + + {/* TODO(screenshot): Scopes picker with admin granular scopes selected */} + + + In **Activation**, resolve any remaining blockers and activate the app + + + Tokens cannot be minted until the app is activated — and deactivating it later immediately kills every token it has issued, stopping all workflows that use the credential. + + + + +### 2. Choose Scopes + +Server-to-Server OAuth apps use Zoom's granular scopes with account-level (`:admin`) variants. Grant only what your workflows use. The set Sim's Zoom tools can draw on is: + +**Users:** +``` +user:read:user:admin +``` + +**Meetings:** +``` +meeting:read:meeting:admin +meeting:read:list_meetings:admin +meeting:write:meeting:admin +meeting:update:meeting:admin +meeting:delete:meeting:admin +meeting:read:invitation:admin +meeting:read:list_past_participants:admin +``` + +**Cloud recordings:** +``` +cloud_recording:read:list_user_recordings:admin +cloud_recording:read:list_recording_files:admin +cloud_recording:delete:recording_file:admin +``` + + +Match these against what the Marketplace **Scopes** picker actually offers — Zoom's granular-scope catalog shows the `:admin` variants for account-level apps, but names occasionally differ from the user-level scopes Sim's OAuth flow requests. If a picker entry differs slightly from this list, prefer the picker's name for the matching action. + + +A missing scope surfaces at run time as a `4xx` error from the Zoom API naming a scope problem. Add the scope in the app's **Scopes** section and re-run the workflow — no changes are needed in Sim. + +### 3. Copy the Credentials + +The Client secret is bearer material for your entire Zoom account, limited only by the app's scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts it at rest. + + +Regenerating the Client secret in the Zoom Marketplace invalidates the stored pair. If you rotate it, update the credential in Sim right away. + + +## Adding the Service Account to Sim + + + + Open **Integrations** from your workspace sidebar + + + Search for "Zoom" and open it, then click **Add to Sim** and choose **Add server-to-server app** + + {/* TODO(screenshot): Zoom integration page with the Add server-to-server app connect option */} + + + In the **Add Zoom server-to-server app** dialog, paste the **Client ID**, **Client secret**, and **Account ID**, and optionally set a display name and description + + {/* TODO(screenshot): Add Zoom server-to-server app dialog with all three fields filled in */} + + + Click **Add server-to-server app**. Sim verifies the credentials by minting a real access token from Zoom — if it fails, the error tells you whether Zoom rejected the credentials or couldn't be reached. A rejection usually means bad credentials, an app that isn't a Server-to-Server OAuth app, or an app that hasn't been activated. + + + +## Using the Service Account in Workflows + +Add a Zoom block to your workflow. In the credential dropdown, your Zoom service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. + +{/* TODO(screenshot): Zoom block in a workflow with the Zoom service account selected as the credential */} + +The block calls `api.zoom.us` with a freshly minted access token — the same requests as the OAuth flow, so every Zoom tool works, subject to the scopes you granted. + + +**The `me` keyword does not work with service accounts.** With a personal OAuth connection, user-scoped operations (create meeting, list meetings, list recordings) accept `me` for the connected user. A Server-to-Server app has no user context — Zoom rejects `me` with an error like "This API does not support client credentials for authorization." Always pass an explicit user ID or email address in the block's user field when using a service account. + + + +Sim's Zoom tools call the standard `api.zoom.us` host. Standard commercial Zoom accounts work as-is; Zoom for Government and other regionally partitioned accounts may not be reachable at that host and aren't currently supported with service accounts. + + +## Token Behavior + +Access tokens minted from the app live for one hour and there is no refresh token — Sim simply mints a new token when one is needed. Zoom explicitly allows multiple concurrently valid tokens, so minting never invalidates workflows already in flight. Two events do kill tokens: + +- **Deactivating the app** — all minted tokens die immediately and no new ones can be issued until it's reactivated +- **Regenerating the Client secret** — the stored credentials stop working; paste the new secret into the credential in Sim + + diff --git a/apps/docs/content/docs/en/platform/enterprise/forks.mdx b/apps/docs/content/docs/en/platform/enterprise/forks.mdx index 08f7e3bfd3a..7185eb39627 100644 --- a/apps/docs/content/docs/en/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/forks.mdx @@ -117,6 +117,19 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from " --- +## Excluded workflows + +The **Excluded workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure. Check a workflow — or a whole folder at once — to keep it out of forking entirely. Think of it as a `.gitignore` for syncs: + +- **Never sent** — pushes from this workspace do not carry it, the other side pulling from this workspace does not receive it, and creating a new fork does not copy it +- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side + +The setting belongs to **this workspace's copy** only. Excluding a workflow here does not exclude its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so un-excluding later resumes updating the same counterpart instead of creating a duplicate. + +**Example:** a staging fork excludes `Scratch experiment` so it can never reach production, and production excludes `Billing hotfix` so no push from staging can ever overwrite it. + +--- + ## Activity **See activity** (or the Activity view from the Forks header) lists forks, pushes, pulls, and rollbacks that involve this workspace — including events recorded on the other side of the edge. @@ -156,8 +169,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a | Resource | Fork | Sync | |----------|------|------| -| Deployed workflows | Always copied as drafts | Updated / created / archived (force overwrite) | +| Deployed workflows | Copied as drafts (unless excluded) | Updated / created / archived (force overwrite) | | Undeployed workflows | Not copied | Not synced | +| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived | | Files | Optional copy (default on) | Map or copy | | Tables | Optional copy (default on) | Map or copy | | Knowledge bases (+ documents) | Optional copy; referenced docs come with the KB | Map or copy; documents follow the KB | @@ -176,7 +190,7 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a ### Workflows -Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. +Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. Workflows marked [excluded](#excluded-workflows) never move in either direction. | | Behavior | |---|----------| diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 4e222b68dd3..3e9c2212abd 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -72,7 +72,7 @@ import { Callout } from 'fumadocs-ui/components/callout' ## File Storage -By default Sim writes uploads to local disk. For production, point it at AWS S3 or Azure Blob. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy. +By default Sim writes uploads to local disk. For production, point it at AWS S3, Azure Blob, or Google Cloud Storage. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy. | Variable | Description | |----------|-------------| @@ -82,6 +82,9 @@ By default Sim writes uploads to local disk. For production, point it at AWS S3 | `S3_BUCKET_NAME` | General workspace files bucket — set with `AWS_REGION` to enable S3 | | `AZURE_STORAGE_CONTAINER_NAME` | General files container — set with Azure credentials to enable Blob (takes precedence over S3) | | `AZURE_CONNECTION_STRING` | Azure connection string, or use `AZURE_ACCOUNT_NAME` + `AZURE_ACCOUNT_KEY` | +| `GCS_BUCKET_NAME` | General workspace files bucket — enables GCS when neither Azure Blob nor S3 is configured | +| `GCS_PROJECT_ID` | GCP project ID. Omit to infer from credentials/ADC | +| `GCS_CREDENTIALS_JSON` | Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, `GOOGLE_APPLICATION_CREDENTIALS`) | ## Email Providers diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index a19285358d7..5a15538a0a5 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -1,6 +1,6 @@ --- title: Object Storage -description: Configure where Sim stores uploaded files — local disk, AWS S3, or Azure Blob +description: Configure where Sim stores uploaded files — local disk, AWS S3, Azure Blob, or Google Cloud Storage --- import { Tab, Tabs } from 'fumadocs-ui/components/tabs' @@ -8,16 +8,17 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { FAQ } from '@/components/ui/faq' -Sim stores every uploaded file — knowledge base documents, chat attachments, execution outputs, profile pictures, and more — in object storage. Three backends are supported: +Sim stores every uploaded file — knowledge base documents, chat attachments, execution outputs, profile pictures, and more — in object storage. Four backends are supported: | Backend | When to use | |---------|-------------| | **Local disk** | Single-node Docker, local development, evaluation | | **[AWS S3](https://aws.amazon.com/s3/)** | Production, especially when running more than one app replica | | **[Azure Blob](https://learn.microsoft.com/azure/storage/blobs/)** | Production on Azure | +| **[Google Cloud Storage](https://cloud.google.com/storage)** | Production on GCP | - Local disk writes to the container's `/uploads` directory. Files are lost when the container is recreated unless that path is on a persistent volume, and they are **not** shared across replicas. For any multi-replica or production deployment, use S3 or Azure Blob. + Local disk writes to the container's `/uploads` directory. Files are lost when the container is recreated unless that path is on a persistent volume, and they are **not** shared across replicas. For any multi-replica or production deployment, use S3, Azure Blob, or Google Cloud Storage. ## How the backend is selected @@ -26,9 +27,10 @@ Sim picks the backend automatically from environment variables — there is no e 1. **Azure Blob** — used if `AZURE_STORAGE_CONTAINER_NAME` is set **and** either (`AZURE_ACCOUNT_NAME` + `AZURE_ACCOUNT_KEY`) or `AZURE_CONNECTION_STRING` is set. 2. **AWS S3** — used if `S3_BUCKET_NAME` **and** `AWS_REGION` are set (and Azure is not configured). -3. **Local disk** — the fallback when neither is configured. +3. **Google Cloud Storage** — used if `GCS_BUCKET_NAME` is set (and neither Azure nor S3 is configured). +4. **Local disk** — the fallback when none is configured. -If both Azure and S3 are configured, **Azure wins**. Set only the variables for the backend you intend to use. +If more than one backend is configured, the first match in that order wins. Set only the variables for the backend you intend to use. ## Set up AWS S3 @@ -201,6 +203,143 @@ AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos A full Helm example lives at `helm/sim/examples/values-azure.yaml`. +## Set up Google Cloud Storage + + + + + +### Create the buckets + +GCS uses one bucket per purpose, mirroring the S3 layout: + +```bash +export PROJECT_ID=your-project-id +export LOCATION=us-central1 + +# Create buckets (names must be globally unique — prefix with your org) +for name in workspace-files knowledge-base execution-files chat-files \ + copilot-files profile-pictures og-images workspace-logos; do + gcloud storage buckets create "gs://myorg-sim-$name" \ + --project "$PROJECT_ID" \ + --location "$LOCATION" \ + --uniform-bucket-level-access +done +``` + +Keep all buckets **private** (no `allUsers` bindings). Sim serves files through short-lived V4 signed URLs, so the buckets never need public read access. + +Because uploads are sent **directly from the browser** via signed `PUT` requests, each bucket needs a CORS policy that allows your Sim origin: + +```bash +cat > /tmp/cors.json <<'EOF' +[ + { + "origin": ["https://your-sim-domain.com"], + "method": ["GET", "PUT"], + "responseHeader": [ + "Content-Type", + "ETag", + "x-goog-meta-originalname", + "x-goog-meta-uploadedat", + "x-goog-meta-purpose", + "x-goog-meta-userid", + "x-goog-meta-workspaceid", + "x-goog-meta-workflowid", + "x-goog-meta-executionid" + ], + "maxAgeSeconds": 3600 + } +] +EOF + +for name in workspace-files knowledge-base execution-files chat-files \ + copilot-files profile-pictures og-images workspace-logos; do + gcloud storage buckets update "gs://myorg-sim-$name" --cors-file=/tmp/cors.json +done +``` + + + Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise. + + + + + + +### Grant access + +Create a service account (or reuse the one your workload runs as) and grant it object access on the buckets: + +```bash +gcloud iam service-accounts create sim-storage --project "$PROJECT_ID" + +for name in workspace-files knowledge-base execution-files chat-files \ + copilot-files profile-pictures og-images workspace-logos; do + gcloud storage buckets add-iam-policy-binding "gs://myorg-sim-$name" \ + --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \ + --role roles/storage.objectAdmin +done +``` + +You then have two ways to supply credentials: + +- **Application Default Credentials (recommended on GCP)** — run Sim with the service account via GKE Workload Identity (or attach it to the GCE instance) and leave `GCS_CREDENTIALS_JSON` unset. Because there is no private key in this mode, generating signed URLs uses the IAM `signBlob` API — grant the service account `roles/iam.serviceAccountTokenCreator` **on itself**: + + ```bash + gcloud iam service-accounts add-iam-policy-binding \ + "sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \ + --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \ + --role roles/iam.serviceAccountTokenCreator + ``` + +- **Inline key (for Docker Compose or non-GCP hosts)** — create a JSON key for the service account and set `GCS_CREDENTIALS_JSON` to its contents. With a private key present, signed URLs are generated locally and no extra IAM role is needed. + + + + + +### Configure environment variables + +```bash +# Credentials — omit both when using Workload Identity / ADC +GCS_PROJECT_ID=your-project-id # optional; inferred from credentials when unset +GCS_CREDENTIALS_JSON='{"type":"service_account","client_email":"...","private_key":"..."}' + +# Buckets (per purpose) +GCS_BUCKET_NAME=myorg-sim-workspace-files +GCS_KB_BUCKET_NAME=myorg-sim-knowledge-base +GCS_EXECUTION_FILES_BUCKET_NAME=myorg-sim-execution-files +GCS_CHAT_BUCKET_NAME=myorg-sim-chat-files +GCS_COPILOT_BUCKET_NAME=myorg-sim-copilot-files +GCS_PROFILE_PICTURES_BUCKET_NAME=myorg-sim-profile-pictures +GCS_OG_IMAGES_BUCKET_NAME=myorg-sim-og-images +GCS_WORKSPACE_LOGOS_BUCKET_NAME=myorg-sim-workspace-logos +``` + +Only `GCS_BUCKET_NAME` is strictly required to switch Sim into GCS mode. Every purpose-specific bucket falls back to the general bucket when unset — add the others so each file type lands in its own bucket. + + + + + +### GCS bucket reference + +| Variable | Stores | Required | +|----------|--------|----------| +| `GCS_BUCKET_NAME` | General workspace files | **Yes** (enables GCS) | +| `GCS_PROJECT_ID` | GCP project ID | No (inferred from credentials/ADC) | +| `GCS_CREDENTIALS_JSON` | Inline service-account JSON | No (uses Application Default Credentials if unset) | +| `GCS_KB_BUCKET_NAME` | Knowledge base documents | Recommended (falls back to `GCS_BUCKET_NAME`) | +| `GCS_EXECUTION_FILES_BUCKET_NAME` | Workflow execution files | Recommended (falls back to `GCS_BUCKET_NAME`) | +| `GCS_CHAT_BUCKET_NAME` | Deployed chat assets | Recommended (falls back to `GCS_BUCKET_NAME`) | +| `GCS_COPILOT_BUCKET_NAME` | Copilot attachments | Recommended (falls back to `GCS_BUCKET_NAME`) | +| `GCS_PROFILE_PICTURES_BUCKET_NAME` | User avatars | Recommended (falls back to `GCS_BUCKET_NAME`) | +| `GCS_OG_IMAGES_BUCKET_NAME` | OpenGraph preview images (falls back to `GCS_BUCKET_NAME`) | Optional | +| `GCS_WORKSPACE_LOGOS_BUCKET_NAME` | Workspace logos (falls back to `GCS_BUCKET_NAME`) | Optional | + +A full Helm example (Workload Identity, GKE) lives at `helm/sim/examples/values-gcp.yaml`. + ## Set up an S3-compatible provider (R2, MinIO, B2) Sim works with any S3-compatible store by pointing the S3 client at a custom endpoint. Configure it exactly like AWS S3 (buckets, access key, secret), then add `S3_ENDPOINT` — and `S3_FORCE_PATH_STYLE` where the provider requires path-style addressing. Verified with [Cloudflare R2](https://developers.cloudflare.com/r2/), [MinIO](https://min.io/), [Backblaze B2](https://www.backblaze.com/cloud-storage), and [RustFS](https://rustfs.com/). @@ -280,10 +419,11 @@ After restarting with the new configuration: If uploads fail, check the app logs for credential or permission errors (see [Troubleshooting](/platform/self-hosting/troubleshooting)). diff --git a/apps/pii/engines.py b/apps/pii/engines.py deleted file mode 100644 index c8edf982ff1..00000000000 --- a/apps/pii/engines.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Analyzer engine builders for the PII service. - -Two NER engines share one recognizer surface: - -- spacy (default): the 5 large spaCy models do NER (PERSON/LOCATION/NRP/ - DATE_TIME) and tokenization. -- gliner (opt-in): one multilingual GLiNER model does NER on CPU or GPU; - small spaCy models remain only for tokenization + lemmas. - -Both engines register the identical regex/checksum recognizer set (Presidio -defaults, EXTRA_RECOGNIZERS, VIN) — only the source of the 4 NER entity types -differs. Side-effect free: importing this module loads no models. -""" - -import importlib.util - -import spacy.util -from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer -from presidio_analyzer.nlp_engine import NlpEngineProvider -from presidio_analyzer.predefined_recognizers import ( - AuAbnRecognizer, - AuAcnRecognizer, - AuMedicareRecognizer, - AuTfnRecognizer, - EsNieRecognizer, - EsNifRecognizer, - FiPersonalIdentityCodeRecognizer, - GLiNERRecognizer, - InAadhaarRecognizer, - InPanRecognizer, - InPassportRecognizer, - InVehicleRegistrationRecognizer, - InVoterRecognizer, - ItDriverLicenseRecognizer, - ItFiscalCodeRecognizer, - ItIdentityCardRecognizer, - ItPassportRecognizer, - ItVatCodeRecognizer, - PlPeselRecognizer, - SgFinRecognizer, - SgUenRecognizer, - UkNinoRecognizer, -) - -# Languages served. Each needs its spaCy model installed in the image; the -# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...) -# auto-load once their NLP engine is present. -NLP_CONFIGURATION = { - "nlp_engine_name": "spacy", - "models": [ - {"lang_code": "en", "model_name": "en_core_web_lg"}, - {"lang_code": "es", "model_name": "es_core_news_lg"}, - {"lang_code": "it", "model_name": "it_core_news_lg"}, - {"lang_code": "pl", "model_name": "pl_core_news_lg"}, - {"lang_code": "fi", "model_name": "fi_core_news_lg"}, - ], -} -SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]] - -# The gliner engine still needs a spaCy pipeline per language: the regex -# recognizers consume NlpArtifacts and the LemmaContextAwareEnhancer boosts -# scores from surrounding lemmas. The small models (~12-40MB each vs ~400MB -# large) keep tokenization + lemmas intact while GLiNER owns NER. Blank -# pipelines ("blank:xx") are not an option: Presidio's SpacyNlpEngine treats -# unknown model names as pip packages and tries to download them. -# labels_to_ignore strips the small models' NER output from NlpArtifacts — -# correctness comes from removing SpacyRecognizer in build_gliner_analyzer; -# this only silences unmapped-label noise. -GLINER_NLP_CONFIGURATION = { - "nlp_engine_name": "spacy", - "models": [ - {"lang_code": "en", "model_name": "en_core_web_sm"}, - {"lang_code": "es", "model_name": "es_core_news_sm"}, - {"lang_code": "it", "model_name": "it_core_news_sm"}, - {"lang_code": "pl", "model_name": "pl_core_news_sm"}, - {"lang_code": "fi", "model_name": "fi_core_news_sm"}, - ], - "ner_model_configuration": { - "labels_to_ignore": [ - "CARDINAL", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW", - "LOC", "MISC", "MONEY", "NORP", "ORDINAL", "ORG", "PER", - "PERCENT", "PERSON", "PRODUCT", "QUANTITY", "TIME", "WORK_OF_ART", - ], - }, -} - -# Zero-shot label prompts -> the 4 Presidio NER entities GLiNER owns. Multiple -# prompts per entity trade a little inference cost for recall; tune against -# scripts/bench_engines.py output. -GLINER_ENTITY_MAPPING = { - "person": "PERSON", - "name": "PERSON", - "location": "LOCATION", - "address": "LOCATION", - "date": "DATE_TIME", - "time": "DATE_TIME", - "nationality": "NRP", - "religious group": "NRP", - "political group": "NRP", - "ethnic group": "NRP", -} - -# Predefined recognizers Presidio ships but does NOT load into the default -# registry — they must be added explicitly. Each carries its own -# supported_language, so it fires under that language once its NLP model is -# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids. -EXTRA_RECOGNIZERS = [ - UkNinoRecognizer, - AuAbnRecognizer, - AuAcnRecognizer, - AuTfnRecognizer, - AuMedicareRecognizer, - InPanRecognizer, - InAadhaarRecognizer, - InVehicleRegistrationRecognizer, - InVoterRecognizer, - InPassportRecognizer, - SgFinRecognizer, - SgUenRecognizer, - EsNifRecognizer, - EsNieRecognizer, - ItFiscalCodeRecognizer, - ItDriverLicenseRecognizer, - ItVatCodeRecognizer, - ItPassportRecognizer, - ItIdentityCardRecognizer, - PlPeselRecognizer, - FiPersonalIdentityCodeRecognizer, -] - - -class VinRecognizer(PatternRecognizer): - """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit - validation (position 9). Validation makes accidental matches on arbitrary - 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some - non-North-American VINs omit the check digit and are skipped — an - intentional bias toward precision. - """ - - _TRANSLIT = { - **{str(d): d for d in range(10)}, - "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, - "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9, - "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9, - } - _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] - - def validate_result(self, pattern_text: str): - vin = pattern_text.upper() - if len(vin) != 17: - return False - try: - total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS)) - except KeyError: - return False - check = total % 11 - expected = "X" if check == 10 else str(check) - return vin[8] == expected - - -class SharedModelGLiNERRecognizer(GLiNERRecognizer): - """Per-language GLiNER recognizer sharing ONE loaded model. - - Presidio routes recognizers by supported_language, so the registry holds - one instance per served language — but each instance's load() would pull - its own ~1.2GB model copy. The first instance loads (an ImportError from - a missing gliner package propagates — fail fast in the lean image); the - rest reuse the cached model. - """ - - _shared_models: dict = {} - - def load(self) -> None: - key = (self.model_name, self.map_location) - cached = self._shared_models.get(key) - if cached is None: - super().load() - self._shared_models[key] = self.gliner - else: - self.gliner = cached - - def analyze(self, text, entities, nlp_artifacts=None): - """GLiNERRecognizer appends any requested entity it doesn't know as an - ad-hoc zero-shot label and returns its hits. The analyzer passes ALL - supported entities (~40) when a request doesn't narrow them, which - would prompt GLiNER for CREDIT_CARD/VIN/ES_NIF/... — wrong scope, and - inference cost scales with label count. Restrict to the NER entities - this recognizer owns.""" - requested = [e for e in (entities or self.supported_entities) if e in self.supported_entities] - if not requested: - return [] - return super().analyze(text, requested, nlp_artifacts) - - -def _register_common_recognizers(analyzer: AnalyzerEngine) -> None: - """Regex/checksum recognizers shared by both engines.""" - # VIN is language-agnostic, so register it under every served language — - # a recognizer only fires for the language the caller routes to. - vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7) - for language in SUPPORTED_LANGUAGES: - analyzer.registry.add_recognizer( - VinRecognizer( - supported_entity="VIN", - patterns=[vin_pattern], - context=["vin", "vehicle", "chassis"], - supported_language=language, - ) - ) - for recognizer_cls in EXTRA_RECOGNIZERS: - analyzer.registry.add_recognizer(recognizer_cls()) - - -def build_spacy_analyzer() -> AnalyzerEngine: - nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine() - analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) - _register_common_recognizers(analyzer) - return analyzer - - -def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine: - """GLiNER engine: one multilingual zero-shot model replaces spaCy NER for - PERSON/LOCATION/NRP/DATE_TIME; everything else is unchanged. - - :param model_name: HuggingFace id of the GLiNER model. - :param device: torch device ("cpu", "cuda", "cuda:0"); None auto-detects - via Presidio's device_detector (cuda when available, else cpu). - """ - # Fail fast with an actionable message when gliner deps are missing (e.g. - # a custom-built image without them). Without these checks Presidio would - # try to pip-download the missing spaCy models at startup (a silent - # network fallback that dies with an unrelated pip permission error), and - # the gliner ImportError would surface only later. - if importlib.util.find_spec("gliner") is None: - raise RuntimeError( - "PII_ENGINE=gliner but the gliner package is not installed; " - "use the stock pii image (docker/pii.Dockerfile ships torch + gliner)" - ) - missing = [ - m["model_name"] - for m in GLINER_NLP_CONFIGURATION["models"] - if not spacy.util.is_package(m["model_name"]) - ] - if missing: - raise RuntimeError( - f"PII_ENGINE=gliner needs spaCy models {missing}; " - "use the stock pii image (docker/pii.Dockerfile ships them)" - ) - nlp_engine = NlpEngineProvider(nlp_configuration=GLINER_NLP_CONFIGURATION).create_engine() - analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) - # The default registry wires SpacyRecognizer per language; with GLiNER - # owning the NER entities it would emit duplicate/competing spans from the - # small models' ner pipe. remove_recognizer only logs when nothing matched, - # so assert the removal actually happened. - analyzer.registry.remove_recognizer("SpacyRecognizer") - if any(r.name == "SpacyRecognizer" for r in analyzer.registry.recognizers): - raise RuntimeError("SpacyRecognizer removal failed; Presidio registry layout changed") - for language in SUPPORTED_LANGUAGES: - analyzer.registry.add_recognizer( - SharedModelGLiNERRecognizer( - entity_mapping=GLINER_ENTITY_MAPPING, - model_name=model_name, - map_location=device, - supported_language=language, - ) - ) - _register_common_recognizers(analyzer) - return analyzer diff --git a/apps/pii/requirements-dev.txt b/apps/pii/requirements-dev.txt deleted file mode 100644 index 68a538f5f1d..00000000000 --- a/apps/pii/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Test-only deps. Unit tests need requirements.txt + this file (no models); -# integration tests additionally need the models baked into the docker images -# (see tests/test_integration.py). -pytest==9.0.3 -httpx==0.28.1 diff --git a/apps/pii/requirements-gliner.txt b/apps/pii/requirements-gliner.txt deleted file mode 100644 index a3d002f3850..00000000000 --- a/apps/pii/requirements-gliner.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Extras for the opt-in GLiNER engine — installed ONLY in the `gliner` -# Dockerfile target, on top of requirements.txt. Pinned for reproducible image -# builds; bump deliberately. presidio-analyzer 2.2.362 requires -# gliner >=0.2.13,<1.0.0. -# -# torch is pinned in the Dockerfile instead: the CPU and CUDA targets install -# the same version from different wheel indexes. -gliner==0.2.27 -transformers==5.3.0 -huggingface_hub==1.3.0 diff --git a/apps/pii/scripts/bench_engines.py b/apps/pii/scripts/bench_engines.py deleted file mode 100644 index b7ea70ca764..00000000000 --- a/apps/pii/scripts/bench_engines.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Benchmark + parity harness for the spacy vs gliner NER engines. - -Runs the same payload through both engines and reports per-engine throughput -(batch analyze, the production /redact_batch path) and per-text latency, plus -an accuracy diff over the 4 NER entity types (PERSON/LOCATION/NRP/DATE_TIME). -Non-NER (regex/checksum) results must be identical between engines — both -register the same recognizers — so any mismatch there is a wiring bug and the -script exits non-zero. - -Meant to run inside the pii image (both engines ship in it): - - docker run --rm python scripts/bench_engines.py - docker run --rm -v $PWD/texts.json:/data.json \\ - python scripts/bench_engines.py --payload /data.json - -Payload format: JSON list of {"text": str, "language": str} objects. -This doubles as the tuning harness for GLINER_ENTITY_MAPPING label prompts. -""" - -import argparse -import json -import statistics -import sys -import time -from collections import defaultdict -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import engines # noqa: E402 - -# Entities sourced from the NER models rather than regex/checksum patterns. -# ORGANIZATION is emitted by the spacy engine's NER on unfiltered requests but -# is not in the app's supported set and has no GLiNER mapping — it shows up in -# the NER diff (spacy-only) rather than failing the regex-parity gate. -NER_ENTITIES = {"PERSON", "LOCATION", "NRP", "DATE_TIME", "ORGANIZATION"} -DEFAULT_PAYLOAD = Path(__file__).resolve().parent / "bench_payload.json" - - -def parse_args(): - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--payload", type=Path, default=DEFAULT_PAYLOAD) - parser.add_argument("--engines", default="spacy,gliner") - parser.add_argument("--runs", type=int, default=3) - parser.add_argument("--warmup", type=int, default=1) - parser.add_argument("--device", default=None, help="torch device for gliner (default: auto)") - parser.add_argument("--gliner-model", default="urchade/gliner_multi_pii-v1") - parser.add_argument("--max-examples", type=int, default=10) - parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") - return parser.parse_args() - - -def build(engine: str, args) -> tuple: - started = time.perf_counter() - if engine == "spacy": - analyzer = engines.build_spacy_analyzer() - elif engine == "gliner": - analyzer = engines.build_gliner_analyzer(model_name=args.gliner_model, device=args.device) - else: - raise ValueError(f"Unknown engine {engine!r}") - return analyzer, time.perf_counter() - started - - -def analyze_all(analyzer, items) -> list[list]: - """One analyze() call per text, in payload order.""" - return [analyzer.analyze(text=item["text"], language=item["language"]) for item in items] - - -def bench(analyzer, items, runs: int, warmup: int) -> dict: - for _ in range(warmup): - analyze_all(analyzer, items) - run_times = [] - latencies = [] - for _ in range(runs): - run_started = time.perf_counter() - for item in items: - text_started = time.perf_counter() - analyzer.analyze(text=item["text"], language=item["language"]) - latencies.append(time.perf_counter() - text_started) - run_times.append(time.perf_counter() - run_started) - total_chars = sum(len(item["text"]) for item in items) - avg_run = statistics.mean(run_times) - return { - "texts_per_sec": len(items) / avg_run, - "chars_per_sec": total_chars / avg_run, - "latency_p50_ms": statistics.median(latencies) * 1000, - "latency_p95_ms": statistics.quantiles(latencies, n=20)[18] * 1000, - } - - -def spans(results, keep_ner: bool) -> set: - return { - (r.entity_type, r.start, r.end) - for r in results - if (r.entity_type in NER_ENTITIES) == keep_ner - } - - -def iou(a: tuple, b: tuple) -> float: - inter = max(0, min(a[2], b[2]) - max(a[1], b[1])) - union = max(a[2], b[2]) - min(a[1], b[1]) - return inter / union if union else 0.0 - - -def diff_ner(items, results_a, results_b, max_examples: int) -> dict: - """Per-entity-type agreement between two engines (span IoU >= 0.5).""" - per_type = defaultdict(lambda: {"a_total": 0, "b_total": 0, "matched": 0}) - examples = [] - for item, res_a, res_b in zip(items, results_a, results_b): - a = sorted(spans(res_a, keep_ner=True)) - b = sorted(spans(res_b, keep_ner=True)) - unmatched_b = set(b) - for span_a in a: - per_type[span_a[0]]["a_total"] += 1 - match = next( - (s for s in unmatched_b if s[0] == span_a[0] and iou(span_a, s) >= 0.5), None - ) - if match: - per_type[span_a[0]]["matched"] += 1 - unmatched_b.discard(match) - for span_b in b: - per_type[span_b[0]]["b_total"] += 1 - only_a = [s for s in a if not any(s[0] == t[0] and iou(s, t) >= 0.5 for t in b)] - only_b = sorted(unmatched_b) - if (only_a or only_b) and len(examples) < max_examples: - examples.append( - { - "text": item["text"], - "language": item["language"], - "only_a": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_a], - "only_b": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_b], - } - ) - return {"per_type": dict(per_type), "examples": examples} - - -def diff_regex(items, results_a, results_b) -> list: - """Non-NER results must be identical: same recognizers on both engines.""" - mismatches = [] - for item, res_a, res_b in zip(items, results_a, results_b): - a = spans(res_a, keep_ner=False) - b = spans(res_b, keep_ner=False) - if a != b: - mismatches.append({"text": item["text"], "only_a": sorted(a - b), "only_b": sorted(b - a)}) - return mismatches - - -def main() -> int: - args = parse_args() - items = json.loads(args.payload.read_text()) - engine_names = [e.strip() for e in args.engines.split(",") if e.strip()] - - report = {"payload": str(args.payload), "texts": len(items), "engines": {}} - results_by_engine = {} - for name in engine_names: - analyzer, build_secs = build(name, args) - stats = bench(analyzer, items, runs=args.runs, warmup=args.warmup) - stats["build_secs"] = build_secs - report["engines"][name] = stats - results_by_engine[name] = analyze_all(analyzer, items) - - exit_code = 0 - if set(engine_names) >= {"spacy", "gliner"}: - report["ner_diff"] = diff_ner( - items, results_by_engine["spacy"], results_by_engine["gliner"], args.max_examples - ) - regex_mismatches = diff_regex( - items, results_by_engine["spacy"], results_by_engine["gliner"] - ) - report["regex_mismatches"] = regex_mismatches - if regex_mismatches: - exit_code = 1 - - if args.json: - print(json.dumps(report, indent=2, default=str)) - return exit_code - - for name, stats in report["engines"].items(): - print(f"\n== {name} ==") - print(f" build: {stats['build_secs']:.1f}s") - print(f" throughput: {stats['texts_per_sec']:.2f} texts/s ({stats['chars_per_sec']:.0f} chars/s)") - print(f" latency: p50 {stats['latency_p50_ms']:.1f}ms p95 {stats['latency_p95_ms']:.1f}ms") - if "ner_diff" in report: - print("\n== NER parity (spacy=a vs gliner=b, span IoU>=0.5) ==") - for entity, counts in sorted(report["ner_diff"]["per_type"].items()): - print( - f" {entity:<10} spacy={counts['a_total']:<4} gliner={counts['b_total']:<4} " - f"matched={counts['matched']}" - ) - for example in report["ner_diff"]["examples"]: - print(f"\n [{example['language']}] {example['text']}") - if example["only_a"]: - print(f" spacy only: {', '.join(example['only_a'])}") - if example["only_b"]: - print(f" gliner only: {', '.join(example['only_b'])}") - if report["regex_mismatches"]: - print("\n!! REGEX MISMATCHES (wiring bug — engines must agree on non-NER):") - for mismatch in report["regex_mismatches"]: - print(f" {mismatch}") - else: - print("\n regex/checksum entities: identical across engines ✓") - return exit_code - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/pii/scripts/bench_payload.json b/apps/pii/scripts/bench_payload.json deleted file mode 100644 index fd0f207164d..00000000000 --- a/apps/pii/scripts/bench_payload.json +++ /dev/null @@ -1,97 +0,0 @@ -[ - { "text": "My name is John Smith and I live in Paris with my wife Marie.", "language": "en" }, - { - "text": "Dr. Angela Rodriguez will see you on March 14, 2026 at 3:30 PM in the Boston clinic.", - "language": "en" - }, - { - "text": "Contact Sarah O'Connor at sarah.oconnor@example.com or call (212) 555-0123.", - "language": "en" - }, - { "text": "The package ships to 45 Queen Street, Toronto, next Tuesday.", "language": "en" }, - { - "text": "Ahmed is a practicing Muslim from Egypt who moved to Berlin in 2019.", - "language": "en" - }, - { "text": "She is a Catholic Norwegian citizen born on 12/05/1988.", "language": "en" }, - { - "text": "Payment with card 4111111111111111 was declined yesterday at 10am.", - "language": "en" - }, - { - "text": "The vehicle VIN 1HGCM82633A004352 was registered to James Wilson in Ohio.", - "language": "en" - }, - { - "text": "His National Insurance number is AB123456C and he lives in Manchester.", - "language": "en" - }, - { - "text": "Meeting rescheduled: Friday, 9 January 2026, 14:00, with Priya Natarajan from Mumbai.", - "language": "en" - }, - { "text": "Send the invoice to accounts@acme.io before the end of Q1 2026.", "language": "en" }, - { - "text": "Klaus, a German engineer, and his Buddhist colleague Mei flew from Munich to Osaka.", - "language": "en" - }, - { - "text": "The Democrats and Republicans debated in Washington on election night.", - "language": "en" - }, - { - "text": "No PII here: the quarterly revenue grew 14% and margins held steady.", - "language": "en" - }, - { - "text": "Server request id a7f3k2m9x1q8w5z2b is not a VIN and not a person.", - "language": "en" - }, - { - "text": "Me llamo María García y vivo en Madrid desde el 3 de mayo de 2020.", - "language": "es" - }, - { "text": "Mi NIF es 12345678Z y mi correo es maria.garcia@ejemplo.es.", "language": "es" }, - { - "text": "El señor Javier Morales, ciudadano mexicano, llegó a Barcelona el lunes.", - "language": "es" - }, - { "text": "La reunión con Carmen será el 15 de junio de 2026 en Sevilla.", "language": "es" }, - { - "text": "Los musulmanes y los católicos convivieron durante siglos en Córdoba.", - "language": "es" - }, - { "text": "Mi chiamo Marco Rossi e abito a Roma vicino al Colosseo.", "language": "it" }, - { - "text": "Il codice fiscale di Maria Rossi è RSSMRA85T10A562S, nata il 10 dicembre 1985.", - "language": "it" - }, - { - "text": "Giulia Bianchi, cittadina italiana, si trasferì a Milano nel gennaio 2021.", - "language": "it" - }, - { - "text": "L'appuntamento con il dottor Ferrari è fissato per il 20 marzo 2026 a Torino.", - "language": "it" - }, - { "text": "Nazywam się Jan Kowalski i mieszkam w Warszawie od 2015 roku.", "language": "pl" }, - { - "text": "Mój numer PESEL to 44051401359, urodziłem się 14 maja 1944 w Krakowie.", - "language": "pl" - }, - { - "text": "Anna Nowak, obywatelka polska, spotka się z nami we wtorek 12 maja 2026.", - "language": "pl" - }, - { "text": "Katolicy i protestanci wspólnie świętowali w Gdańsku.", "language": "pl" }, - { - "text": "Nimeni on Matti Virtanen ja asun Helsingissä Töölön kaupunginosassa.", - "language": "fi" - }, - { - "text": "Henkilötunnukseni on 131052-308T ja synnyin Tampereella lokakuussa 1952.", - "language": "fi" - }, - { "text": "Liisa Korhonen muutti Ouluun maanantaina 5. tammikuuta 2026.", "language": "fi" }, - { "text": "Suomalaiset ja ruotsalaiset kilpailevat jääkiekossa joka vuosi.", "language": "fi" } -] diff --git a/apps/pii/server.py b/apps/pii/server.py index 3f59cc540aa..b60368eb58c 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -1,54 +1,270 @@ """Combined Presidio REST service: analyzer + anonymizer on one port. -Constructs one warm AnalyzerEngine (multi-language NLP + a native check-digit -VIN recognizer) and one AnonymizerEngine at startup, exposing stock-compatible -endpoints so a single PRESIDIO_URL serves both. - -NER engine selection (see engines.py): -- PII_ENGINE=spacy (default): the 5 large spaCy models, unchanged behavior. -- PII_ENGINE=gliner: one multilingual GLiNER model for PERSON/LOCATION/NRP/ - DATE_TIME. The stock image ships both engines, so this is a pure env flip. - PII_DEVICE picks cpu/cuda (unset = auto-detect), PII_GLINER_MODEL overrides - the model id. The same code runs on CPU and GPU. Each uvicorn worker - (PII_WORKERS) loads its own GLiNER model copy — into GPU memory when on - cuda — so GPU deployments generally want PII_WORKERS=1 per GPU, unlike the - CPU/spacy path where workers scale with vCPUs. +Constructs one warm AnalyzerEngine (5 large spaCy models for NER + +regex/checksum pattern recognizers, incl. a native check-digit VIN recognizer) +and one AnonymizerEngine at startup, exposing stock-compatible endpoints so a +single PII_URL serves both. """ import logging -import os import time from typing import Any -from engines import build_gliner_analyzer, build_spacy_analyzer from fastapi import FastAPI -from presidio_analyzer import AnalyzerEngine, BatchAnalyzerEngine, RecognizerResult +from presidio_analyzer import ( + AnalyzerEngine, + BatchAnalyzerEngine, + Pattern, + PatternRecognizer, + RecognizerResult, +) +from presidio_analyzer.nlp_engine import NlpEngineProvider +from presidio_analyzer.predefined_recognizers import ( + AuAbnRecognizer, + AuAcnRecognizer, + AuMedicareRecognizer, + AuTfnRecognizer, + EsNieRecognizer, + EsNifRecognizer, + FiPersonalIdentityCodeRecognizer, + InAadhaarRecognizer, + InPanRecognizer, + InPassportRecognizer, + InVehicleRegistrationRecognizer, + InVoterRecognizer, + ItDriverLicenseRecognizer, + ItFiscalCodeRecognizer, + ItIdentityCardRecognizer, + ItPassportRecognizer, + ItVatCodeRecognizer, + PlPeselRecognizer, + SgFinRecognizer, + SgUenRecognizer, + SpacyRecognizer, + UkNinoRecognizer, +) from presidio_anonymizer import AnonymizerEngine from presidio_anonymizer.entities import OperatorConfig from pydantic import BaseModel -PII_ENGINE = os.environ.get("PII_ENGINE", "spacy") -if PII_ENGINE not in ("spacy", "gliner"): - raise ValueError(f"Invalid PII_ENGINE={PII_ENGINE!r}; expected 'spacy' or 'gliner'") -# Empty/unset -> None -> auto-detect (cuda when torch sees a GPU, else cpu). -PII_DEVICE = os.environ.get("PII_DEVICE") or None -PII_GLINER_MODEL = os.environ.get("PII_GLINER_MODEL", "urchade/gliner_multi_pii-v1") - -# Propagates to uvicorn's root handler, so timing lands in the container log stream. -logger = logging.getLogger("sim.pii") +# Languages served. Each needs its spaCy model installed in the image; the +# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...) +# auto-load once their NLP engine is present. +NLP_CONFIGURATION = { + "nlp_engine_name": "spacy", + "models": [ + {"lang_code": "en", "model_name": "en_core_web_lg"}, + {"lang_code": "es", "model_name": "es_core_news_lg"}, + {"lang_code": "it", "model_name": "it_core_news_lg"}, + {"lang_code": "pl", "model_name": "pl_core_news_lg"}, + {"lang_code": "fi", "model_name": "fi_core_news_lg"}, + ], +} +SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]] + +# spaCy pipeline components PII detection does not use. NER depends only on +# `tok2vec` + `ner`; the parser (the most expensive stage), tagger, morphologizer, +# attribute_ruler and lemmatizer are dead weight here. Disabling them is a ~2x +# throughput win with NO change to the detected entities. The only side effect is +# that Presidio's lemma-based context score-boosting weakens slightly — an +# acceptable trade for the speed on this CPU-bound service. +NER_ONLY_DISABLE = ("parser", "tagger", "morphologizer", "attribute_ruler", "lemmatizer") + +# Predefined recognizers Presidio ships but does NOT load into the default +# registry — they must be added explicitly. Each carries its own +# supported_language, so it fires under that language once its NLP model is +# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids. +EXTRA_RECOGNIZERS = [ + UkNinoRecognizer, + AuAbnRecognizer, + AuAcnRecognizer, + AuTfnRecognizer, + AuMedicareRecognizer, + InPanRecognizer, + InAadhaarRecognizer, + InVehicleRegistrationRecognizer, + InVoterRecognizer, + InPassportRecognizer, + SgFinRecognizer, + SgUenRecognizer, + EsNifRecognizer, + EsNieRecognizer, + ItFiscalCodeRecognizer, + ItDriverLicenseRecognizer, + ItVatCodeRecognizer, + ItPassportRecognizer, + ItIdentityCardRecognizer, + PlPeselRecognizer, + FiPersonalIdentityCodeRecognizer, +] + + +class VinRecognizer(PatternRecognizer): + """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit + validation (position 9). Validation makes accidental matches on arbitrary + 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some + non-North-American VINs omit the check digit and are skipped — an + intentional bias toward precision. + """ + + _TRANSLIT = { + **{str(d): d for d in range(10)}, + "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, + "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9, + "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9, + } + _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] + + def validate_result(self, pattern_text: str): + vin = pattern_text.upper() + if len(vin) != 17: + return False + try: + total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS)) + except KeyError: + return False + check = total % 11 + expected = "X" if check == 10 else str(check) + return vin[8] == expected + + +def _register_common_recognizers(analyzer: AnalyzerEngine) -> None: + """Regex/checksum recognizers on top of spaCy NER + the Presidio defaults.""" + # VIN is language-agnostic, so register it under every served language — + # a recognizer only fires for the language the caller routes to. + vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7) + for language in SUPPORTED_LANGUAGES: + analyzer.registry.add_recognizer( + VinRecognizer( + supported_entity="VIN", + patterns=[vin_pattern], + context=["vin", "vehicle", "chassis"], + supported_language=language, + ) + ) + for recognizer_cls in EXTRA_RECOGNIZERS: + analyzer.registry.add_recognizer(recognizer_cls()) def build_analyzer() -> AnalyzerEngine: - if PII_ENGINE == "gliner": - return build_gliner_analyzer(model_name=PII_GLINER_MODEL, device=PII_DEVICE) - return build_spacy_analyzer() - - -logger.info("building analyzer engine=%s device=%s", PII_ENGINE, PII_DEVICE or "auto") + nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine() + for nlp in getattr(nlp_engine, "nlp", {}).values(): + for pipe in NER_ONLY_DISABLE: + if pipe in nlp.pipe_names: + nlp.disable_pipe(pipe) + analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) + _register_common_recognizers(analyzer) + return analyzer + + +# Own handler at INFO. uvicorn configures only its own loggers, not the root, so a +# bare getLogger propagates to a handler-less root at the default WARNING level and +# every info() (the per-request timing lines) is silently dropped. Attach a stream +# handler directly and stop propagation so timing lands in the container log stream +# regardless of uvicorn's config or worker count. +logger = logging.getLogger("sim.pii") +logger.setLevel(logging.INFO) +if not logger.handlers: + _log_handler = logging.StreamHandler() + _log_handler.setFormatter(logging.Formatter("%(levelname)s: %(name)s: %(message)s")) + logger.addHandler(_log_handler) + logger.propagate = False + +logger.info("building analyzer (spacy)") analyzer = build_analyzer() 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. +_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 +) + +# 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. +# 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. +_BLANK_ARTIFACTS = { + language: analyzer.nlp_engine.process_text("", language) + for language in SUPPORTED_LANGUAGES +} + + +def _regex_only(entities: list[str] | None, score_threshold: float | None) -> bool: + """True when the spaCy NLP pass can be skipped: the request names entities, none + require spaCy NER, and no positive score_threshold is set. The blank-artifacts + fast path drops context-based score boosting, which can only change what is + returned when a threshold gates a match between its base and context-boosted + score — so fall back to the full path whenever a threshold is in play.""" + return ( + bool(entities) + and NER_ENTITIES.isdisjoint(entities) + and (score_threshold is None or score_threshold <= 0) + ) + + +def _analyze_one( + text: str, + language: str, + entities: list[str] | None, + score_threshold: float | None, + return_decision_process: bool = False, +): + # Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass; + # otherwise analyze() computes artifacts (runs spaCy) as usual. + nlp_artifacts = ( + _BLANK_ARTIFACTS.get(language) if _regex_only(entities, score_threshold) else None + ) + return analyzer.analyze( + text=text, + language=language, + entities=entities or None, + score_threshold=score_threshold, + return_decision_process=return_decision_process, + nlp_artifacts=nlp_artifacts, + ) + + +def _analyze_many( + texts: list[str], + language: str, + entities: list[str] | None, + score_threshold: float | None, +): + """Analyze many texts, skipping the spaCy pass for regex-only requests.""" + if _regex_only(entities, score_threshold): + blank = _BLANK_ARTIFACTS.get(language) + return [ + analyzer.analyze( + text=text, + language=language, + entities=entities, + score_threshold=score_threshold, + nlp_artifacts=blank, + ) + for text in texts + ] + return list( + batch_analyzer.analyze_iterator( + texts=texts, + language=language, + entities=entities or None, + score_threshold=score_threshold, + ) + ) + + app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None) @@ -150,12 +366,12 @@ def supported_entities(language: str = "en") -> list[str]: @app.post("/analyze") def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]: started = time.perf_counter() - results = analyzer.analyze( - text=req.text, - language=req.language, - entities=req.entities or None, - score_threshold=req.score_threshold, - return_decision_process=req.return_decision_process, + results = _analyze_one( + req.text, + req.language, + req.entities, + req.score_threshold, + req.return_decision_process, ) logger.info( "analyze lang=%s chars=%d entities=%d duration_ms=%.1f", @@ -171,12 +387,7 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]: def analyze_batch(req: AnalyzeBatchRequest) -> list[list[dict[str, Any]]]: """Analyze many texts in one pass (spaCy nlp.pipe), returning one span list per input in request order — the batched counterpart to /analyze.""" - results = batch_analyzer.analyze_iterator( - texts=req.texts, - language=req.language, - entities=req.entities or None, - score_threshold=req.score_threshold, - ) + results = _analyze_many(req.texts, req.language, req.entities, req.score_threshold) return [[r.to_dict() for r in per_text] for per_text in results] @@ -228,12 +439,7 @@ def redact(req: RedactRequest) -> dict[str, str]: anonymizer directly (no dict round-trip).""" started = time.perf_counter() operators = build_operators(req.anonymizers or req.operators) - results = analyzer.analyze( - text=req.text, - language=req.language, - entities=req.entities or None, - score_threshold=req.score_threshold, - ) + results = _analyze_one(req.text, req.language, req.entities, req.score_threshold) text = ( req.text if not results @@ -261,14 +467,7 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]: texts that actually matched.""" started = time.perf_counter() operators = build_operators(req.anonymizers or req.operators) - analyzed = list( - batch_analyzer.analyze_iterator( - texts=req.texts, - language=req.language, - entities=req.entities or None, - score_threshold=req.score_threshold, - ) - ) + analyzed = _analyze_many(req.texts, req.language, req.entities, req.score_threshold) masked: list[str] = [] total_spans = 0 for text, per_text in zip(req.texts, analyzed): @@ -282,9 +481,11 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]: ).text ) logger.info( - "redact_batch lang=%s texts=%d spans=%d duration_ms=%.1f", + "redact_batch lang=%s texts=%d entities=%s nlp=%s spans=%d duration_ms=%.1f", req.language, len(req.texts), + len(req.entities) if req.entities else "all", + "skip" if _regex_only(req.entities, req.score_threshold) else "full", total_spans, (time.perf_counter() - started) * 1000, ) diff --git a/apps/pii/tests/conftest.py b/apps/pii/tests/conftest.py deleted file mode 100644 index c9787a309d9..00000000000 --- a/apps/pii/tests/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys -from pathlib import Path - -# server.py / engines.py live one level up (repo: apps/pii, image: /app). -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/apps/pii/tests/test_engines.py b/apps/pii/tests/test_engines.py deleted file mode 100644 index 1e6c1b8840c..00000000000 --- a/apps/pii/tests/test_engines.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Unit tests for engines.py — no models, no downloads, no network. - -Run: pip install -r requirements.txt -r requirements-dev.txt && python -m pytest tests -""" - -import importlib.util -import os -import subprocess -import sys -from pathlib import Path - -import pytest -from presidio_analyzer.predefined_recognizers.ner import gliner_recognizer - -import engines - -PII_DIR = Path(__file__).resolve().parent.parent - - -class FakeModel: - def __init__(self): - self.seen_labels: list[list[str]] = [] - - def predict_entities(self, text, labels, flat_ner=True, threshold=0.3, multi_label=False): - self.seen_labels.append(list(labels)) - return [{"label": "person", "score": 0.92, "start": 0, "end": 4, "text": text[0:4]}] - - -class FakeGLiNER: - calls = 0 - - @classmethod - def from_pretrained(cls, model_name, **kwargs): - cls.calls += 1 - return FakeModel() - - -@pytest.fixture -def fake_gliner(monkeypatch): - monkeypatch.setattr(gliner_recognizer, "GLiNER", FakeGLiNER) - engines.SharedModelGLiNERRecognizer._shared_models.clear() - FakeGLiNER.calls = 0 - yield FakeGLiNER - engines.SharedModelGLiNERRecognizer._shared_models.clear() - - -def make_recognizer(language: str): - return engines.SharedModelGLiNERRecognizer( - entity_mapping=engines.GLINER_ENTITY_MAPPING, - model_name="fake/model", - map_location="cpu", - supported_language=language, - ) - - -def test_invalid_pii_engine_fails_import(): - result = subprocess.run( - [sys.executable, "-c", "import server"], - cwd=PII_DIR, - env={**os.environ, "PII_ENGINE": "bogus"}, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "Invalid PII_ENGINE" in result.stderr - - -@pytest.mark.skipif( - importlib.util.find_spec("gliner") is not None, - reason="fail-fast path only exists when gliner is not installed", -) -def test_build_gliner_analyzer_fails_fast_without_gliner(): - with pytest.raises(RuntimeError, match="gliner package is not installed"): - engines.build_gliner_analyzer(model_name="fake/model", device="cpu") - - -def test_shared_model_loads_once_across_languages(fake_gliner): - first = make_recognizer("en") - second = make_recognizer("es") - assert fake_gliner.calls == 1 - assert first.gliner is second.gliner - - -def test_analyze_never_prompts_gliner_with_foreign_entities(fake_gliner): - recognizer = make_recognizer("en") - all_supported = ["PERSON", "LOCATION", "NRP", "DATE_TIME", "CREDIT_CARD", "VIN", "ES_NIF"] - results = recognizer.analyze("John went home", entities=all_supported) - for labels in recognizer.gliner.seen_labels: - assert set(labels) <= set(engines.GLINER_ENTITY_MAPPING) - assert results and results[0].entity_type == "PERSON" - - -def test_analyze_skips_inference_when_no_owned_entity_requested(fake_gliner): - recognizer = make_recognizer("en") - assert recognizer.analyze("4111111111111111", entities=["CREDIT_CARD"]) == [] - assert recognizer.gliner.seen_labels == [] - - -def test_entity_mapping_targets_exactly_the_ner_entities(): - assert set(engines.GLINER_ENTITY_MAPPING.values()) == { - "PERSON", - "LOCATION", - "NRP", - "DATE_TIME", - } diff --git a/apps/pii/tests/test_integration.py b/apps/pii/tests/test_integration.py deleted file mode 100644 index 40c97975754..00000000000 --- a/apps/pii/tests/test_integration.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Integration tests — exercise the real engines end-to-end via the FastAPI app. - -Requires the models present, so run inside the built images (gated behind -RUN_PII_INTEGRATION to keep plain `pytest` runs model-free): - - # spacy regression (default engine) - docker run --rm -e RUN_PII_INTEGRATION=1 python -m pytest tests - - # gliner engine - docker run --rm -e RUN_PII_INTEGRATION=1 -e PII_ENGINE=gliner \ - python -m pytest tests/test_integration.py - -The suite adapts to PII_ENGINE: shared assertions always run, engine-specific -ones only for the active engine. -""" - -import os - -import pytest - -if not os.environ.get("RUN_PII_INTEGRATION"): - pytest.skip( - "integration tests need the built image (RUN_PII_INTEGRATION=1)", - allow_module_level=True, - ) - -from fastapi.testclient import TestClient - -import server - -ENGINE = server.PII_ENGINE -client = TestClient(server.app) - - -def redact_batch(texts, language="en"): - response = client.post("/redact_batch", json={"texts": texts, "language": language}) - assert response.status_code == 200 - return response.json()["texts"] - - -def test_health(): - assert client.get("/health").json() == {"status": "ok"} - - -def test_masks_person_and_email(): - [masked] = redact_batch(["My name is John Smith, email john.smith@example.com."]) - assert "" in masked - assert "" in masked - assert "John Smith" not in masked - assert "john.smith@example.com" not in masked - - -def test_masks_location_and_phone(): - [masked] = redact_batch(["I live in Paris, call me at (212) 555-0123."]) - assert "" in masked - assert "" in masked - assert "Paris" not in masked - - -def test_regex_recognizers_fire_in_non_english_languages(): - [masked] = redact_batch(["Mi NIF es 12345678Z."], language="es") - assert "" in masked - # On the spacy engine the it_core_news_lg NER tags the fiscal code as - # ORGANIZATION and outscores the pattern recognizer, so only assert the - # value is masked; the exact label is checked on the gliner engine where - # spaCy NER can't compete. - [masked] = redact_batch(["Il codice fiscale è RSSMRA85T10A562S."], language="it") - assert "RSSMRA85T10A562S" not in masked - if ENGINE == "gliner": - assert "" in masked - - -def test_vin_checksum_recognizer_fires(): - [masked] = redact_batch(["The car VIN is 1HGCM82633A004352."]) - assert "" in masked - - -def test_no_pii_passes_through_unchanged(): - # NB: "Quarterly" would be tagged DATE_TIME by the spacy engine — keep - # this text free of anything either engine considers an entity. - text = "Revenue grew and margins held steady." - assert redact_batch([text]) == [text] - - -@pytest.mark.skipif(ENGINE != "gliner", reason="gliner-only wiring assertions") -def test_gliner_registry_has_no_spacy_recognizer(): - names = {r.name for r in server.analyzer.registry.recognizers} - assert "SpacyRecognizer" not in names - assert "GLiNERRecognizer" in names - - -@pytest.mark.skipif(ENGINE != "gliner", reason="gliner-only wiring assertions") -def test_gliner_supported_entities_keep_ner_types(): - supported = set(server.analyzer.get_supported_entities("en")) - assert {"PERSON", "LOCATION", "NRP", "DATE_TIME"} <= supported - - -@pytest.mark.skipif(ENGINE != "spacy", reason="spacy-only wiring assertions") -def test_spacy_registry_unchanged(): - names = {r.name for r in server.analyzer.registry.recognizers} - assert "SpacyRecognizer" in names - assert "GLiNERRecognizer" not in names diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f46957ed851..349d017e792 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -81,7 +81,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # CONTEXT_DEV_API_KEY_1= # Context.dev API key #1 # CONTEXT_DEV_API_KEY_2= # Context.dev API key #2 -# File Storage (Optional - defaults to local disk; use S3 or Azure Blob for production) +# File Storage (Optional - defaults to local disk; use S3, Azure Blob, or Google Cloud Storage for production) # AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2 # AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain # AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain @@ -99,7 +99,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login) # INSTAGRAM_CLIENT_ID= # INSTAGRAM_CLIENT_SECRET= -# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). +# Instagram publish file uploads require cloud storage above — S3, Azure Blob, or GCS (Meta must fetch a public HTTPS URL). # Gmail attachments do not need this. # TikTok OAuth (Optional - Client Key/Secret from the TikTok for Developers app) @@ -119,6 +119,18 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME= # OpenGraph preview images (falls back to AZURE_STORAGE_CONTAINER_NAME) # AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME= # Workspace logos (falls back to AZURE_STORAGE_CONTAINER_NAME) +# Google Cloud Storage (used when neither Azure Blob nor S3 is configured) +# GCS_PROJECT_ID= # GCP project ID (optional — inferred from credentials/ADC when unset) +# GCS_CREDENTIALS_JSON= # Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS) +# GCS_BUCKET_NAME= # General workspace files bucket (enables GCS; all other buckets fall back to it) +# GCS_KB_BUCKET_NAME= # Knowledge base documents +# GCS_EXECUTION_FILES_BUCKET_NAME= # Workflow execution files +# GCS_CHAT_BUCKET_NAME= # Deployed chat assets +# GCS_COPILOT_BUCKET_NAME= # Copilot attachments +# GCS_PROFILE_PICTURES_BUCKET_NAME= # User profile pictures +# GCS_OG_IMAGES_BUCKET_NAME= # OpenGraph preview images +# GCS_WORKSPACE_LOGOS_BUCKET_NAME= # Workspace logos + # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces @@ -133,4 +145,6 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # EXECUTION_TIMEOUT_ASYNC_FREE=5400 # Async execution timeout in seconds # FREE_TABLES_LIMIT=5 # Max user tables per workspace # FREE_TABLE_ROWS_LIMIT=50000 # Max rows per user table +# TABLE_DISPATCH_CONCURRENCY_FREE=20 # Rows one table run executes in parallel on free tier +# TABLE_DISPATCH_CONCURRENCY_PAID=50 # Rows one table run executes in parallel on paid tiers (billing disabled uses this) # FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx index 740deccce9f..9fef6524507 100644 --- a/apps/sim/app/(landing)/layout.tsx +++ b/apps/sim/app/(landing)/layout.tsx @@ -6,9 +6,18 @@ import { isHosted } from '@/lib/core/config/env-flags' import { SITE_URL } from '@/lib/core/utils/urls' import { LandingShell } from '@/app/(landing)/components' import { HubspotPageViewTracker } from '@/app/(landing)/hubspot-page-view-tracker' +import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker' const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const +const X_PIXEL_ID = 'q5xbl' as const + +/** X (Twitter) conversion tracking base code — loads uwt.js and fires the initial PageView. */ +const X_PIXEL_BASE_CODE = `!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments); +},s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js', +a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script'); +twq('config','${X_PIXEL_ID}');` + /** * Route-group layout for the entire landing family - the home page, platform and * solutions pages, pricing, legal, and the marketing subroutes (blog, models, @@ -33,12 +42,16 @@ export default function LandingLayout({ children }: { children: ReactNode }) { return ( {children} - {/* HubSpot tracking — hosted only */} + {/* HubSpot + X pixel tracking — hosted only */} {isHosted && ( <> + )} diff --git a/apps/sim/app/(landing)/x-page-view-tracker.tsx b/apps/sim/app/(landing)/x-page-view-tracker.tsx new file mode 100644 index 00000000000..5db1e0439fd --- /dev/null +++ b/apps/sim/app/(landing)/x-page-view-tracker.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { usePathname, useSearchParams } from 'next/navigation' + +declare global { + interface Window { + twq?: (...args: unknown[]) => void + } +} + +// next/script dedupes by id and never reloads on remount, so this must be +// module-scope (not a ref) to survive LandingLayout unmounting/remounting. +let hasTrackedInitialPageView = false + +/** + * The X pixel base code only auto-tracks the first page load; LandingLayout + * persists across client-side navigations, so the pixel never sees the rest. + * Re-fires the pixel's PageView via `twq('config', ...)` on every navigation + * after the first. + */ +export function XPageViewTracker() { + const pathname = usePathname() + const searchParams = useSearchParams() + const query = searchParams.toString() + + // Instance-scoped (not module-scoped) so a Strict Mode replay of this + // mount's effect is skipped, while a fresh mount — returning to the landing + // layout from the app — starts empty and tracks the view again. + const lastTrackedUrlRef = useRef(null) + + useEffect(() => { + const url = query ? `${pathname}?${query}` : pathname + if (lastTrackedUrlRef.current === url) return + lastTrackedUrlRef.current = url + + if (!hasTrackedInitialPageView) { + hasTrackedInitialPageView = true + return + } + + window.twq?.('config', 'q5xbl') + }, [pathname, query]) + + return null +} diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 7c56fbf2e7b..22f110a0eb1 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -8,6 +8,7 @@ import { client } from '@/lib/auth/auth-client' import { type AppSession, extractSessionDataFromAuthClientResult, + getActiveOrganizationId, } from '@/lib/auth/session-response' import { sessionKeys, useSessionQuery } from '@/hooks/queries/session' @@ -71,7 +72,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { queryClient.invalidateQueries({ queryKey: ['organizations'] }) queryClient.invalidateQueries({ queryKey: ['subscription'] }) - const activeOrganizationId = session?.session?.activeOrganizationId ?? null + const activeOrganizationId = getActiveOrganizationId(session) if (!session || activeOrganizationId) { return } diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index ad468ac3b9f..e1ef6105675 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -12,16 +12,21 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorizeCredentialUse } = vi.hoisted(() => ({ +const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({ mockAuthorizeCredentialUse: vi.fn(), + mockResolveServiceAccountToken: vi.fn(), })) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/app/api/auth/oauth/utils', () => ({ + ...authOAuthUtilsMock, + resolveServiceAccountToken: mockResolveServiceAccountToken, +})) vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUse: mockAuthorizeCredentialUse, })) +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { GET, POST } from '@/app/api/auth/oauth/token/route' describe('OAuth Token API Routes', () => { @@ -195,6 +200,107 @@ describe('OAuth Token API Routes', () => { expect(data).toHaveProperty('error', 'Failed to refresh access token') }) + describe('service account path', () => { + it('should thread authStyle from the resolver into the response', async () => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'sa-credential-id', + credentialType: 'service_account', + providerId: 'pipedrive-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + mockResolveServiceAccountToken.mockResolvedValueOnce({ + accessToken: 'pasted-api-token', + authStyle: 'x-api-token', + }) + + const req = createMockRequest('POST', { credentialId: 'sa-credential-id' }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toHaveProperty('accessToken', 'pasted-api-token') + expect(data).toHaveProperty('authStyle', 'x-api-token') + }) + + it('should omit authStyle for Bearer token-paste providers', async () => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'sa-credential-id', + credentialType: 'service_account', + providerId: 'hubspot-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + mockResolveServiceAccountToken.mockResolvedValueOnce({ + accessToken: 'pat-token', + }) + + const req = createMockRequest('POST', { credentialId: 'sa-credential-id' }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toHaveProperty('accessToken', 'pat-token') + expect(data).not.toHaveProperty('authStyle') + }) + + it.each([ + ['invalid_credentials', 401], + ['site_not_found', 400], + ['provider_unavailable', 502], + ] as const)( + 'surfaces the %s error code with status %i when the mint fails', + async (code, status) => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'sa-credential-id', + credentialType: 'service_account', + providerId: 'salesforce-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + mockResolveServiceAccountToken.mockRejectedValueOnce( + new TokenServiceAccountValidationError(code, status, { step: 'mint' }) + ) + + const req = createMockRequest('POST', { credentialId: 'sa-credential-id' }) + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(status) + // provider_unavailable is an infra failure, not a credential error, so + // it intentionally does not carry a client-actionable `code`. + if (code === 'provider_unavailable') { + expect(data).not.toHaveProperty('code') + } else { + expect(data).toHaveProperty('code', code) + } + } + ) + }) + describe('credentialAccountUserId + providerId path', () => { it('should reject unauthenticated requests', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index ccc557389cb..b767b59325e 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -11,6 +11,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { captureServerEvent } from '@/lib/posthog/server' import { getCredential, @@ -180,11 +181,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: result.accessToken, cloudId: result.cloudId, domain: result.domain, + instanceUrl: result.instanceUrl, + authStyle: result.authStyle, }, { status: 200 } ) } catch (error) { logger.error(`[${requestId}] Service account token error:`, error) + if (error instanceof TokenServiceAccountValidationError) { + // Classified provider outages are infra failures, not bad credentials. + if (error.code === 'provider_unavailable') { + return NextResponse.json( + { error: 'Credential provider is temporarily unavailable' }, + { status: 502 } + ) + } + // A stored host that no longer resolves is a configuration failure — + // surface the code so runtime consumers can say "check the host" + // instead of a generic auth error. + if (error.code === 'site_not_found') { + return NextResponse.json( + { + code: error.code, + error: 'Credential host not found — reconnect the credential with a valid host', + }, + { status: 400 } + ) + } + // A revoked/rotated-away or misconfigured stored secret — surface the + // code so runtime consumers can prompt to reconnect the credential + // rather than showing a generic auth failure. + if (error.code === 'invalid_credentials') { + return NextResponse.json( + { + code: error.code, + error: 'Credential rejected by the provider — reconnect the credential', + }, + { status: 401 } + ) + } + } return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 }) } } diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 77457cf3328..0e6550d44ea 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -20,8 +20,15 @@ vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })), })) +const { mockMinter } = vi.hoisted(() => ({ mockMinter: vi.fn() })) +vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ + getClientCredentialAccountMinter: vi.fn(() => mockMinter), + parseClientCredentialAccountSecretBlob: vi.fn((decrypted: string) => JSON.parse(decrypted)), +})) + import { db } from '@sim/db' import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight' +import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' import { refreshOAuthToken } from '@/lib/oauth' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, @@ -331,4 +338,129 @@ describe('OAuth Utils', () => { ).rejects.toThrow(/Scopes are required/) }) }) + + describe('resolveServiceAccountToken — client-credential mint cache', () => { + const ENCRYPTED_KEY_A = `${'a'.repeat(32)}rest-of-ciphertext` + const ENCRYPTED_KEY_B = `${'b'.repeat(32)}rest-of-ciphertext` + const BLOB_FIELDS = { clientId: 'cid', clientSecret: 'cs', orgId: 'org' } + + let now: number + let dateNowSpy: ReturnType + + beforeEach(() => { + now = 1_750_000_000_000 + dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(BLOB_FIELDS) }) + }) + + afterEach(() => { + dateNowSpy.mockRestore() + }) + + function mockCredentialRow(encryptedServiceAccountKey: string) { + mockSelectChain([{ encryptedServiceAccountKey }]) + } + + it('mints once with skipIdentity, then serves cache hits preserving instanceUrl', async () => { + const credId = 'ccsa-cache-hit' + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ + accessToken: 'tok-1', + expiresInSeconds: 3600, + instanceUrl: 'https://org.my.salesforce.com', + }) + + const first = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(first).toEqual({ + accessToken: 'tok-1', + instanceUrl: 'https://org.my.salesforce.com', + }) + expect(mockMinter).toHaveBeenCalledWith(BLOB_FIELDS, { skipIdentity: true }) + + mockCredentialRow(ENCRYPTED_KEY_A) + const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(second).toEqual({ + accessToken: 'tok-1', + instanceUrl: 'https://org.my.salesforce.com', + }) + expect(mockMinter).toHaveBeenCalledTimes(1) + }) + + it('re-mints when remaining validity is below the 5-minute serve floor', async () => { + const credId = 'ccsa-ttl-floor' + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'tok-1', expiresInSeconds: 240 }) + + await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'tok-2', expiresInSeconds: 3600 }) + const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(second.accessToken).toBe('tok-2') + expect(mockMinter).toHaveBeenCalledTimes(2) + }) + + it('re-mints when the stored secret fingerprint changes (credential rotation)', async () => { + const credId = 'ccsa-rotation' + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'old-app-token', expiresInSeconds: 3600 }) + + await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + mockCredentialRow(ENCRYPTED_KEY_B) + mockMinter.mockResolvedValueOnce({ accessToken: 'new-app-token', expiresInSeconds: 3600 }) + const second = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(second.accessToken).toBe('new-app-token') + expect(mockMinter).toHaveBeenCalledTimes(2) + }) + + it('never caches a failed mint as a token but memoizes the failure for ~30s', async () => { + const credId = 'ccsa-negative-memo' + const mintError = new Error('invalid_credentials') + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockRejectedValueOnce(mintError) + + await expect( + resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + ).rejects.toBe(mintError) + + mockCredentialRow(ENCRYPTED_KEY_A) + await expect( + resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + ).rejects.toBe(mintError) + expect(mockMinter).toHaveBeenCalledTimes(1) + + now += 31_000 + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'tok-after', expiresInSeconds: 3600 }) + const result = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(result.accessToken).toBe('tok-after') + expect(mockMinter).toHaveBeenCalledTimes(2) + }) + + it('evicts the cached token when the credential row is deleted', async () => { + const credId = 'ccsa-deleted' + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'tok-1', expiresInSeconds: 3600 }) + + await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + mockSelectChain([]) + await expect( + resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + ).rejects.toThrow(/secret not found/) + + mockCredentialRow(ENCRYPTED_KEY_A) + mockMinter.mockResolvedValueOnce({ accessToken: 'tok-2', expiresInSeconds: 3600 }) + const result = await resolveServiceAccountToken(credId, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID) + + expect(result.accessToken).toBe('tok-2') + expect(mockMinter).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 5bd9ce0090c..4fd56f0e0a7 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -7,7 +7,15 @@ import { and, desc, eq } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + getClientCredentialAccountMinter, + parseClientCredentialAccountSecretBlob, +} from '@/lib/credentials/client-credential-accounts/server' +import { + getTokenServiceAccountDescriptor, + isTokenServiceAccountProviderId, +} from '@/lib/credentials/token-service-accounts/descriptors' import { parseTokenServiceAccountSecretBlob, type TokenServiceAccountSecretBlob, @@ -337,6 +345,14 @@ export interface ServiceAccountTokenResult { cloudId?: string /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ domain?: string + /** Salesforce only — the org's instance URL the token must be used against. */ + instanceUrl?: string + /** + * Set when the token must be sent in an `x-api-token` header instead of + * `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means + * Bearer; OAuth credentials never carry it. + */ + authStyle?: 'x-api-token' } /** @@ -362,6 +378,156 @@ async function getTokenServiceAccountSecret( return parseTokenServiceAccountSecretBlob(decrypted, providerId) } +interface CachedClientCredentialToken { + accessToken: string + expiresAtMs: number + /** + * Fingerprint of the encrypted secret the token was minted from (see + * {@link secretFingerprintOf}). A reconnect that re-points the credential at + * different client credentials changes the ciphertext, so a mismatch means + * the cached token belongs to the old app and must be re-minted. + */ + secretFingerprint: string + /** Salesforce only — the instance URL returned alongside the minted token. */ + instanceUrl?: string +} + +interface FailedClientCredentialMint { + /** The error the failed mint threw, re-thrown to callers while memoized. */ + error: unknown + secretFingerprint: string + expiresAtMs: number +} + +/** + * Per-instance cache of minted client-credential access tokens (Zoom S2S, + * Box CCG, Salesforce client-credentials), keyed by credential id. Entries are + * served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of + * validity remains, so a hot credential mints roughly once per token TTL + * (~1h for Zoom/Box; Salesforce reports a conservative 10-minute TTL because + * its responses never carry an expiry) per instance. + * + * Every resolution re-reads the credential row (a cheap indexed PK select — + * the mint is the expensive part) and validates the cached entry's secret + * fingerprint against the live ciphertext, so rotating or re-pointing a + * credential takes effect on the next resolution on every instance, and a + * credential that is re-resolved after deletion evicts its own entries. No + * cross-instance lock is needed: mints are stateless and these providers allow + * multiple concurrently valid tokens, so each instance minting its own token + * is correct. + * + * Failed mints are never cached as tokens; instead they are memoized for + * {@link CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS} so a hot workflow on a + * revoked/invalid secret doesn't hammer the provider's token endpoint once + * per block execution. + * + * Both maps are pruned of expired entries on each resolution + * ({@link pruneExpiredClientCredentialCaches}), so their size is bounded by the + * credentials resolved within the last token lifetime — entries for credentials + * that are never resolved again do not accumulate indefinitely. + */ +const clientCredentialTokenCache = new Map() +const clientCredentialMintFailureCache = new Map() +const CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS = 5 * 60 * 1000 +const CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS = 30 * 1000 + +/** Drops fully-expired token and failure entries so the maps stay bounded. */ +function pruneExpiredClientCredentialCaches(nowMs: number): void { + for (const [id, entry] of clientCredentialTokenCache) { + if (entry.expiresAtMs <= nowMs) clientCredentialTokenCache.delete(id) + } + for (const [id, entry] of clientCredentialMintFailureCache) { + if (entry.expiresAtMs <= nowMs) clientCredentialMintFailureCache.delete(id) + } +} + +/** + * Rotation fingerprint for a stored encrypted secret: the ciphertext prefix + * (IV + first blocks) is unique per encryption, so any re-encrypt — secret + * rotation or re-pointing at a different app — changes it. + */ +function secretFingerprintOf(encryptedServiceAccountKey: string): string { + return encryptedServiceAccountKey.slice(0, 32) +} + +/** + * Resolves a client-credential service-account credential to a short-lived + * access token: decrypts the stored client id/secret + org id and mints via + * the provider's registered minter (skipping the connect-time identity + * lookup), read-through the per-instance cache. Wrapped in `coalesceLocally` + * so concurrent block executions on one instance share a single mint. + */ +async function resolveClientCredentialAccountToken( + credentialId: string, + providerId: string +): Promise { + return coalesceLocally(`ccsa:${credentialId}`, async () => { + pruneExpiredClientCredentialCaches(Date.now()) + const [credentialRow] = await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + if (!credentialRow?.encryptedServiceAccountKey) { + clientCredentialTokenCache.delete(credentialId) + clientCredentialMintFailureCache.delete(credentialId) + throw new Error('Client-credential service account secret not found') + } + const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey) + + const cached = clientCredentialTokenCache.get(credentialId) + if ( + cached && + cached.secretFingerprint === secretFingerprint && + cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS + ) { + return { accessToken: cached.accessToken, instanceUrl: cached.instanceUrl } + } + + const failed = clientCredentialMintFailureCache.get(credentialId) + if ( + failed && + failed.secretFingerprint === secretFingerprint && + Date.now() < failed.expiresAtMs + ) { + throw failed.error + } + clientCredentialMintFailureCache.delete(credentialId) + + try { + const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + const blob = parseClientCredentialAccountSecretBlob(decrypted, providerId) + const minter = getClientCredentialAccountMinter(providerId) + if (!minter) { + throw new Error(`No minter registered for service-account provider ${providerId}`) + } + + const mint = await minter( + { + clientId: blob.clientId, + clientSecret: blob.clientSecret, + orgId: blob.orgId, + }, + { skipIdentity: true } + ) + clientCredentialTokenCache.set(credentialId, { + accessToken: mint.accessToken, + expiresAtMs: Date.now() + mint.expiresInSeconds * 1000, + secretFingerprint, + instanceUrl: mint.instanceUrl, + }) + return { accessToken: mint.accessToken, instanceUrl: mint.instanceUrl } + } catch (error) { + clientCredentialMintFailureCache.set(credentialId, { + error, + secretFingerprint, + expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, + }) + throw error + } + }) +} + interface ServiceAccountTokenOptions { scopes?: string[] impersonateEmail?: string @@ -412,7 +578,15 @@ export async function resolveServiceAccountToken( ): Promise { if (providerId && isTokenServiceAccountProviderId(providerId)) { const secret = await getTokenServiceAccountSecret(credentialId, providerId) - return { accessToken: secret.apiToken, domain: secret.domain } + const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle + return { + accessToken: secret.apiToken, + domain: secret.domain, + ...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}), + } + } + if (providerId && isClientCredentialAccountProviderId(providerId)) { + return resolveClientCredentialAccountToken(credentialId, providerId) } const resolver = providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId) @@ -660,21 +834,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise } /** - * Refreshes an OAuth token if needed based on credential information. - * Also handles service account credentials by generating a JWT-based token. - * @param credentialId The ID of the credential to check and potentially refresh - * @param userId The user ID who owns the credential (for security verification) - * @param requestId Request ID for log correlation - * @param scopes Optional scopes for service account token generation - * @returns The valid access token or null if refresh fails + * Resolves a credential to its access token plus provider metadata + * (`cloudId`/`domain`/`instanceUrl`/`authStyle`). Behaves exactly like + * {@link refreshAccessTokenIfNeeded} but returns the full + * {@link ServiceAccountTokenResult} so callers that build provider requests + * directly (e.g. selector routes) can honor non-Bearer auth styles such as + * Pipedrive's `x-api-token`. OAuth credentials resolve with `accessToken` + * only. */ -export async function refreshAccessTokenIfNeeded( +export async function resolveCredentialAccessToken( credentialId: string, userId: string, requestId: string, scopes?: string[], impersonateEmail?: string -): Promise { +): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { return null @@ -682,13 +856,12 @@ export async function refreshAccessTokenIfNeeded( if (resolved.credentialType === 'service_account' && resolved.credentialId) { logger.info(`[${requestId}] Using service account token for credential`) - const { accessToken } = await resolveServiceAccountToken( + return resolveServiceAccountToken( resolved.credentialId, resolved.providerId, scopes, impersonateEmail ) - return accessToken } // Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query @@ -745,13 +918,13 @@ export async function refreshAccessTokenIfNeeded( requestId, userId: credential.userId, }) - if (fresh) return fresh + if (fresh) return { accessToken: fresh } // If refresh was only triggered proactively (Microsoft refresh-token aging / // Instagram long-lived nearing expiry), the still-valid access token is fine. if (!accessTokenNeedsRefresh && accessToken) { logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) - return accessToken + return { accessToken } } return null } @@ -762,7 +935,34 @@ export async function refreshAccessTokenIfNeeded( } logger.info(`[${requestId}] Access token is valid for credential`) - return accessToken + return { accessToken } +} + +/** + * Refreshes an OAuth token if needed based on credential information. + * Also handles service account credentials by generating a JWT-based token. + * Thin string wrapper over {@link resolveCredentialAccessToken}. + * @param credentialId The ID of the credential to check and potentially refresh + * @param userId The user ID who owns the credential (for security verification) + * @param requestId Request ID for log correlation + * @param scopes Optional scopes for service account token generation + * @returns The valid access token or null if refresh fails + */ +export async function refreshAccessTokenIfNeeded( + credentialId: string, + userId: string, + requestId: string, + scopes?: string[], + impersonateEmail?: string +): Promise { + const result = await resolveCredentialAccessToken( + credentialId, + userId, + requestId, + scopes, + impersonateEmail + ) + return result?.accessToken ?? null } /** diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts new file mode 100644 index 00000000000..894e543c3b2 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -0,0 +1,323 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockOAuth2LinkAccount, + mockCheckWorkspaceAccess, + mockGetCredentialActorContext, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockOAuth2LinkAccount: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth/auth', () => ({ + auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), +})) + +import { GET } from '@/app/api/auth/oauth2/authorize/route' + +const BASE_URL = 'https://sim.test' +const WORKSPACE_ID = 'ws-1' +const USER_ID = 'user-1' +const CREDENTIAL_ID = 'cred-1' +const LINK_URL = 'https://provider.example/authorize?state=abc' + +function authorizeRequest(query: Record) { + const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value) + } + return createMockRequest('GET', undefined, {}, url.toString()) +} + +function oauthCredentialActor(overrides: Record = {}) { + return { + credential: { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'google-email', + displayName: 'Work Gmail', + ...((overrides.credential as Record) ?? {}), + }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), + } +} + +describe('OAuth2 authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, + }) + mockOAuth2LinkAccount.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ url: LINK_URL }), + headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, + }) + }) + + describe('plain connect (no credentialId)', () => { + it('creates a draft with credentialId null and redirects to the provider', async () => { + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + credentialId: null, + }) + ) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ credentialId: null }), + }) + ) + }) + + it('numbers the draft display name when the default collides with an existing credential', async () => { + dbChainMockFns.where + .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) + .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) + + await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ displayName: "Justin's Gmail 2" }) + ) + }) + + it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { + await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + + const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] + expect(set).toHaveProperty('credentialId', null) + }) + + it('redirects to login when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toContain('/login') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects without workspace write access', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: false, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, + }) + + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) + + describe('reconnect (credentialId present)', () => { + it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockGetCredentialActorContext).toHaveBeenCalledWith( + CREDENTIAL_ID, + USER_ID, + expect.objectContaining({ workspaceAccess: expect.anything() }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: CREDENTIAL_ID }) + ) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), + }) + ) + }) + + it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) + ) + + await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Renamed By User' }) + ) + }) + + it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { + for (const providerId of ['trello', 'shopify']) { + const response = await GET( + authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_reconnect_unsupported` + ) + } + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + + it('rejects when the caller is not a credential admin and writes no draft', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + + it('rejects when the credential belongs to a different workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) + ) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects when the credential does not exist', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + credential: null, + member: null, + hasWorkspaceAccess: false, + canWriteWorkspace: false, + isAdmin: false, + }) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'cred-missing', + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects a non-oauth credential', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { type: 'env_workspace' } }) + ) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects when the query providerId does not match the credential provider', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const response = await GET( + authorizeRequest({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 2a9799d7863..2eb916fa53a 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -28,7 +29,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query + const { + providerId, + workspaceId, + callbackURL: requestedCallback, + credentialId, + } = parsed.data.query const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback @@ -45,10 +51,65 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } + let reconnectDisplayName: string | undefined + if (credentialId) { + // Trello and Shopify authorize through their own custom flows that bypass + // this endpoint, so a reconnect draft written here would linger unconsumed + // and could later be picked up by their token-store callbacks, silently + // rebinding the credential. Mirror the copilot tool and reject reconnect. + if (providerId === 'trello' || providerId === 'shopify') { + logger.warn('Reconnect not supported for custom-flow provider', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + } + + // Reconnect: the OAuth callback will rebind this credential to the fresh + // account, so require the same credential-admin access as the draft POST + // route — workspace write alone must not be enough to swap someone's tokens. + const actor = await getCredentialActorContext(credentialId, userId, { + workspaceAccess: access, + }) + if ( + !actor.credential || + actor.credential.workspaceId !== workspaceId || + actor.credential.type !== 'oauth' || + !actor.isAdmin + ) { + logger.warn('Credential admin access denied for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (actor.credential.providerId !== providerId) { + logger.warn('Provider mismatch for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + credentialProviderId: actor.credential.providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + reconnectDisplayName = actor.credential.displayName + } + // Create the draft before initiating the link so it is guaranteed to exist // (and freshly clocked) when the OAuth callback's `account.create.after` // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ userId, workspaceId, providerId }) + await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, callbackURL }, 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 38b24348167..399caeb3b66 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -26,9 +26,12 @@ const { mockCheckChatAccess } = vi.hoisted(() => ({ const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse +const mockCheckNeedsRedeployment = workflowsApiUtilsMockFns.mockCheckNeedsRedeployment const mockEncryptSecret = encryptionMockFns.mockEncryptSecret const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy +const mockGetWorkflowDeploymentSummary = + workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged @@ -72,7 +75,17 @@ describe('Chat Edit API Route', () => { }) mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 }) + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mockCheckNeedsRedeployment.mockResolvedValue(false) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + version: 1, + latestDeploymentAttempt: { status: 'active' }, + }) mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) }) @@ -200,6 +213,58 @@ describe('Chat Edit API Route', () => { expect(data.message).toBe('Chat deployment updated successfully') }) + 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({ + hasAccess: true, + chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' }, + workspaceId: 'workspace-123', + }) + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { status: 'preparing' }, + warnings: [], + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Updated Chat' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(409) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('skips redeploying when the active version already matches the draft', async () => { + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' }, + workspaceId: 'workspace-123', + }) + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-1', + version: 3, + deployedAt: '2026-07-15T00:00:00.000Z', + }, + latestDeploymentAttempt: { status: 'active' }, + warnings: [], + }) + mockCheckNeedsRedeployment.mockResolvedValue(false) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Updated Chat' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalled() + }) + it('should handle identifier conflicts', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' }, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 707051d7e9f..0afb00b152a 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -12,9 +12,17 @@ import { isDev } from '@/lib/core/config/env-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration' +import { + getWorkflowDeploymentSummary, + performChatUndeploy, + performFullDeploy, +} from '@/lib/workflows/orchestration' import { checkChatAccess } from '@/app/api/chat/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + checkNeedsRedeployment, + createErrorResponse, + createSuccessResponse, +} from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' export const maxDuration = 120 @@ -136,26 +144,59 @@ export const PATCH = withRouteHandler( logger.info('Keeping existing password') } - // Redeploy the workflow to ensure latest version is active - const deployResult = await performFullDeploy({ - workflowId: existingChat[0].workflowId, - userId: session.user.id, - request, - }) + /** + * A settings update only redeploys when the draft actually drifted from + * the active version, and never while another attempt is in flight — + * otherwise each blocked retry would admit a fresh deployment version + * on top of the pending one. + */ + const deploymentSummary = await getWorkflowDeploymentSummary(existingChat[0].workflowId) + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + if (attemptStatus === 'preparing' || attemptStatus === 'activating') { + return createErrorResponse( + 'A workflow deployment is still preparing. Retry the chat update after it becomes active.', + 409 + ) + } - if (!deployResult.success) { - logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) - const status = - deployResult.errorCode === 'validation' - ? 400 - : deployResult.errorCode === 'not_found' - ? 404 - : 500 - return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) + const needsRedeploy = + !deploymentSummary.activeDeployment || + (await checkNeedsRedeployment(existingChat[0].workflowId)) + + if (needsRedeploy) { + const deployResult = await performFullDeploy({ + workflowId: existingChat[0].workflowId, + userId: session.user.id, + }) + + if (!deployResult.success) { + logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) + const status = + deployResult.errorCode === 'validation' + ? 400 + : deployResult.errorCode === 'not_found' + ? 404 + : 500 + return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) + } + /** + * Deploys settle asynchronously: `success` only admits the attempt. + * The chat record must not advance until cutover finished, otherwise + * a later preparation failure strands the chat on the previous + * version with no error. A blocked retry lands in the in-flight gate + * above without admitting another version. Mirrors performChatDeploy. + */ + if (deployResult.latestDeploymentAttempt?.status !== 'active') { + return createErrorResponse( + deployResult.warnings?.[0] ?? + 'Workflow deployment is still preparing. Retry the chat update after it becomes active.', + 409 + ) + } + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) } - logger.info( - `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` - ) const updateData: Record = { updatedAt: new Date(), diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 19f8ba89f1d..c0d72341ed5 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -86,6 +86,9 @@ export const PUT = withRouteHandler( botToken: body.botToken, apiToken: body.apiToken, domain: body.domain, + clientId: body.clientId, + clientSecret: body.clientSecret, + orgId: body.orgId, request, }) if (!result.success) { @@ -96,9 +99,13 @@ export const PUT = withRouteHandler( ? 403 : result.errorCode === 'conflict' ? 409 - : result.errorCode === 'validation' - ? 400 - : 500 + : // A provider outage during reconnect is infra, not a bad + // request — mirror the create route and runtime token route. + result.providerErrorCode === 'provider_unavailable' + ? 502 + : result.errorCode === 'validation' + ? 400 + : 500 return NextResponse.json( { error: result.error, diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts new file mode 100644 index 00000000000..c673b39b1a1 --- /dev/null +++ b/apps/sim/app/api/credentials/route.test.ts @@ -0,0 +1,160 @@ +/** + * Tests for the workspace credentials API route (create path). + * + * @vitest-environment node + */ +import { auditMock, authMockFns, createMockRequest, posthogServerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const { + mockCheckWorkspaceAccess, + mockGetWorkspaceMembership, + mockVerifyAndBuildServiceAccountSecret, +} = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + mockGetWorkspaceMembership: vi.fn(), + mockVerifyAndBuildServiceAccountSecret: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceMembership: mockGetWorkspaceMembership, +})) + +vi.mock('@/lib/credentials/oauth', () => ({ + syncWorkspaceOAuthCredentialsForUser: vi.fn(), +})) + +vi.mock('@/lib/oauth', () => ({ + getServiceConfigByProviderId: vi.fn(), +})) + +vi.mock('@/lib/credentials/atlassian-service-account', () => ({ + AtlassianValidationError: class AtlassianValidationError extends Error {}, +})) + +vi.mock('@/lib/credentials/service-account-secret', () => ({ + verifyAndBuildServiceAccountSecret: mockVerifyAndBuildServiceAccountSecret, + ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, +})) + +import { POST } from '@/app/api/credentials/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +describe('POST /api/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + mockGetWorkspaceMembership.mockResolvedValue({ ownerId: 'user-1', memberUserIds: ['user-1'] }) + }) + + describe('client-credential service accounts', () => { + it('forwards clientId, clientSecret, and orgId to the secret builder on create', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'encrypted-blob', + displayName: 'Zoom account acct_123', + auditMetadata: { zoomAccountId: 'acct_123' }, + }) + + const req = createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'zoom-secret', + orgId: 'acct_123', + }) + + const response = await POST(req) + + expect(response.status).toBe(201) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledTimes(1) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'zoom-service-account', + expect.objectContaining({ + clientId: 'zoom-client-id', + clientSecret: 'zoom-secret', + orgId: 'acct_123', + }) + ) + }) + + it('maps a verification failure to a 400 with the validation code', async () => { + mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( + new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'zoom_token_mint', + }) + ) + + const req = createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'zoom-secret', + orgId: 'acct_123', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ code: 'invalid_credentials', error: 'invalid_credentials' }) + }) + + it('maps a provider outage to a 502, not a 400', async () => { + mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( + new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'zoom_token_mint', + }) + ) + + const req = createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'zoom-secret', + orgId: 'acct_123', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(502) + expect(data).toEqual({ code: 'provider_unavailable', error: 'provider_unavailable' }) + }) + + it('rejects a client-credential create missing the required fields', async () => { + const req = createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('clientSecret is required') + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index e1708f0b48b..2617cb90a2c 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -27,6 +27,7 @@ import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getServiceConfigByProviderId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' @@ -313,6 +314,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { id: clientCredentialId, signingSecret, botToken, + clientId, + clientSecret, + orgId, } = parsed.data.body const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -370,6 +374,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { apiToken, domain, serviceAccountJson, + clientId, + clientSecret, + orgId, }) resolvedProviderId = secret.providerId resolvedAccountId = null @@ -437,6 +444,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + // Token service-account creates always carry a fresh token that must be + // stored — falling through to the existing-credential path would return + // the old credential as success and silently drop the submitted token. + if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + return NextResponse.json( + { + code: 'duplicate_display_name', + error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + }, + { status: 409 } + ) + } + const access = await getCredentialActorContext(existingCredential.id, session.user.id, { workspaceAccess, }) @@ -604,7 +624,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { upstreamStatus: error.status, ...error.logDetail, }) - return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) + // A provider outage is an infra failure, not a bad request — mirror the + // runtime token route so monitoring sees a 502, not a 400. + const status = error.code === 'provider_unavailable' ? 502 : 400 + return NextResponse.json({ code: error.code, error: error.code }, { status }) } if (error instanceof DuplicateCredentialError) { return NextResponse.json( diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 4728f0fd666..97f00a26ff2 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -96,7 +97,7 @@ async function renewExpiringSubscriptions(): Promise<{ .from(webhookTable) .where( and( - eq(webhookTable.isActive, true), + deliverableWebhookPredicate(webhookTable, 'active_only'), or( eq(webhookTable.provider, 'microsoft-teams'), eq(webhookTable.provider, 'microsoftteams') diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index 4409108eacd..e894bb387a0 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -27,8 +27,7 @@ vi.mock('@/lib/uploads', () => ({ })) vi.mock('@/lib/uploads/config', () => ({ - BLOB_CHAT_CONFIG: {}, - S3_CHAT_CONFIG: {}, + getStorageConfig: vi.fn(() => ({})), })) vi.mock('@/lib/uploads/server/metadata', () => ({ diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 8e0c66b4173..2acd14d46a2 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -6,7 +6,6 @@ import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getFileMetadata } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' -import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config' import type { StorageConfig } from '@/lib/uploads/core/storage-client' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' @@ -781,23 +780,6 @@ export async function assertToolFileAccess( * Get chat storage configuration based on current storage provider */ async function getChatStorageConfig(): Promise { - const { USE_S3_STORAGE, USE_BLOB_STORAGE } = await import('@/lib/uploads/config') - - if (USE_BLOB_STORAGE) { - return { - containerName: BLOB_CHAT_CONFIG.containerName, - accountName: BLOB_CHAT_CONFIG.accountName, - accountKey: BLOB_CHAT_CONFIG.accountKey, - connectionString: BLOB_CHAT_CONFIG.connectionString, - } - } - - if (USE_S3_STORAGE) { - return { - bucket: S3_CHAT_CONFIG.bucket, - region: S3_CHAT_CONFIG.region, - } - } - - return {} + const { getStorageConfig } = await import('@/lib/uploads/config') + return getStorageConfig('chat') } diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 8a7e4b292e5..414d781c3a0 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -12,7 +12,7 @@ import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedd import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' -import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -106,7 +106,7 @@ export const GET = withRouteHandler( } if (!isMarkdown(record.originalName, record.contentType)) { - const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const storagePrefix = getServeStoragePrefix() const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` auditExport('file', 0) return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 9ead001c919..07fbac67361 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -48,8 +48,8 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([ /** * Unified part identity sent by the client when completing a multipart upload. - * `etag` is required for S3 (CompleteMultipartUpload). For Azure the server - * derives the block id from `partNumber` via {@link deriveBlobBlockId}. + * `etag` is required for S3 and GCS (CompleteMultipartUpload). For Azure the + * server derives the block id from `partNumber` via {@link deriveBlobBlockId}. */ interface ClientCompletedPart { partNumber: number @@ -77,6 +77,9 @@ const buildBlobCustomConfig = (config: StorageConfig) => ({ connectionString: config.connectionString, }) +const buildGcsCustomConfig = (config: StorageConfig) => + config.bucket ? { bucket: config.bucket } : undefined + const verifyTokenForUser = (token: string | undefined, userId: string) => { if (!token || typeof token !== 'string') { return null @@ -124,7 +127,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!isUsingCloudStorage()) { return NextResponse.json( - { error: 'Multipart upload is only available with cloud storage (S3 or Azure Blob)' }, + { + error: + 'Multipart upload is only available with cloud storage (S3, Azure Blob, or Google Cloud Storage)', + }, { status: 400 } ) } @@ -241,6 +247,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) uploadId = result.uploadId key = result.key + } else if (storageProvider === 'gcs') { + const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + const result = await initiateGcsMultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: buildGcsCustomConfig(config), + customKey, + purpose: context, + }) + uploadId = result.uploadId + key = result.key } else { return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, @@ -308,6 +326,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) return NextResponse.json({ presignedUrls }) } + if (storageProvider === 'gcs') { + const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') + const presignedUrls = await getGcsMultipartPartUrls( + key, + uploadId, + partNumbers, + buildGcsCustomConfig(config) + ) + return NextResponse.json({ presignedUrls }) + } return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, @@ -333,6 +361,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null const blobModule = storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null + const gcsModule = + storageProvider === 'gcs' ? await import('@/lib/uploads/providers/gcs/client') : null const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => { const { uploadId, key, context } = payload @@ -360,6 +390,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { blockId: deriveBlobBlockId(p.partNumber), })) completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config)) + } else if (storageProvider === 'gcs' && gcsModule) { + const { completeGcsMultipartUpload } = gcsModule + const gcsParts = parts.map((p) => { + if (!p.etag) { + throw new Error(`Missing etag for GCS part ${p.partNumber}`) + } + return { ETag: p.etag, PartNumber: p.partNumber } + }) + completed = await completeGcsMultipartUpload( + key, + uploadId, + gcsParts, + buildGcsCustomConfig(config) + ) } else { throw new Error(`Unsupported storage provider: ${storageProvider}`) } @@ -459,6 +503,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') await abortMultipartUpload(key, buildBlobCustomConfig(config)) logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`) + } else if (storageProvider === 'gcs') { + const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await abortGcsMultipartUpload(key, uploadId, buildGcsCustomConfig(config)) + logger.info(`Aborted GCS multipart upload for key ${key} (context: ${context})`) } else { return NextResponse.json( { error: `Unsupported storage provider: ${storageProvider}` }, diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 033f180818a..c1bcf0dbea2 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -489,23 +489,25 @@ async function handleExternalUrl( try { logger.info('Fetching external URL:', url) - const { - S3_EXECUTION_FILES_CONFIG, - BLOB_EXECUTION_FILES_CONFIG, - USE_S3_STORAGE, - USE_BLOB_STORAGE, - } = await import('@/lib/uploads/config') + const { getStorageConfig, USE_S3_STORAGE, USE_BLOB_STORAGE, USE_GCS_STORAGE } = await import( + '@/lib/uploads/config' + ) + const executionConfig = getStorageConfig('execution') let isExecutionFile = false try { const parsedUrl = new URL(url) - if (USE_S3_STORAGE && S3_EXECUTION_FILES_CONFIG.bucket) { - const bucketInHost = parsedUrl.hostname.startsWith(S3_EXECUTION_FILES_CONFIG.bucket) - const bucketInPath = parsedUrl.pathname.startsWith(`/${S3_EXECUTION_FILES_CONFIG.bucket}/`) + if (USE_S3_STORAGE && executionConfig.bucket) { + const bucketInHost = parsedUrl.hostname.startsWith(executionConfig.bucket) + const bucketInPath = parsedUrl.pathname.startsWith(`/${executionConfig.bucket}/`) + isExecutionFile = bucketInHost || bucketInPath + } else if (USE_BLOB_STORAGE && executionConfig.containerName) { + isExecutionFile = url.includes(`/${executionConfig.containerName}/`) + } else if (USE_GCS_STORAGE && executionConfig.bucket) { + const bucketInHost = parsedUrl.hostname.startsWith(`${executionConfig.bucket}.`) + const bucketInPath = parsedUrl.pathname.startsWith(`/${executionConfig.bucket}/`) isExecutionFile = bucketInHost || bucketInPath - } else if (USE_BLOB_STORAGE && BLOB_EXECUTION_FILES_CONFIG.containerName) { - isExecutionFile = url.includes(`/${BLOB_EXECUTION_FILES_CONFIG.containerName}/`) } } catch (error) { logger.warn('Failed to parse URL for execution file check:', error) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index 846c381d051..85dfb85ec08 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -8,7 +8,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' -import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getServeStoragePrefix } from '@/lib/uploads/config' import { generateBatchPresignedUploadUrls, hasCloudStorage, @@ -163,7 +163,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const storagePrefix = getServeStoragePrefix() return NextResponse.json({ files: presignedUrls.map((urlResponse, index) => { diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index cd8c50caa4a..55f37d718ac 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -75,6 +75,7 @@ vi.mock('@/lib/uploads/config', () => ({ return mockUseS3Storage.value }, UPLOAD_DIR: '/uploads', + getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), getStorageConfig: mockGetStorageConfig, isUsingCloudStorage: mockIsUsingCloudStorage, getStorageProvider: mockGetStorageProvider, diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 68893aaeae0..5fd15fbfe5c 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -7,7 +7,7 @@ import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' -import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getServeStoragePrefix } from '@/lib/uploads/config' import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -313,7 +313,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}` + const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}` return NextResponse.json({ fileName, diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 42e11b0cd34..aad77fe0393 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -94,7 +94,8 @@ export const GET = withRouteHandler( const fullPath = path.join('/') const isS3Path = path[0] === 's3' const isBlobPath = path[0] === 'blob' - const isCloudPath = isS3Path || isBlobPath + const isGcsPath = path[0] === 'gcs' + const isCloudPath = isS3Path || isBlobPath || isGcsPath const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath const isPublicByKeyPrefix = diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts index 58dcadbdeb4..aadb2c92c74 100644 --- a/apps/sim/app/api/files/storage-status/route.ts +++ b/apps/sim/app/api/files/storage-status/route.ts @@ -9,7 +9,7 @@ export const dynamic = 'force-dynamic' /** * GET /api/files/storage-status - * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish). + * Whether S3, Azure Blob, or Google Cloud Storage is configured (needed for Instagram file-upload publish). */ export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index 2bdf7663825..0eedc8211cc 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -99,9 +99,9 @@ export function extractFilename(path: string): string { .replace(/\/\.\./g, '') .replace(/\.\.\//g, '') - if (filename.startsWith('s3/') || filename.startsWith('blob/')) { + if (filename.startsWith('s3/') || filename.startsWith('blob/') || filename.startsWith('gcs/')) { const parts = filename.split('/') - const prefix = parts[0] // 's3' or 'blob' + const prefix = parts[0] // 's3', 'blob', or 'gcs' const keyParts = parts.slice(1) const sanitizedKeyParts = keyParts diff --git a/apps/sim/app/api/files/view/[id]/route.ts b/apps/sim/app/api/files/view/[id]/route.ts index e9d68b86821..55ddedcef3a 100644 --- a/apps/sim/app/api/files/view/[id]/route.ts +++ b/apps/sim/app/api/files/view/[id]/route.ts @@ -5,7 +5,7 @@ import { fileViewContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { type StorageContext, USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getServeStoragePrefix, type StorageContext } from '@/lib/uploads/config' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -57,7 +57,7 @@ export const GET = withRouteHandler( ) } - const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' + const storagePrefix = getServeStoragePrefix() const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` logger.info('Redirecting file view to serve path', { id, servePath }) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f14f6a1bfa8..012b6f9f458 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -15,7 +15,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInE2B, mockExecuteInIsolatedVM, + mockFetchWorkspaceFileBuffer, mockGetWorkspaceFile, + mockResolveWorkspaceFileReference, mockUpdateWorkspaceFileContent, mockUploadFile, mockValidateWorkspaceFileWriteTarget, @@ -23,7 +25,9 @@ const { } = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), mockGetWorkspaceFile: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), mockUpdateWorkspaceFileContent: vi.fn(), mockUploadFile: vi.fn(), mockValidateWorkspaceFileWriteTarget: vi.fn(), @@ -37,6 +41,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: mockExecuteInE2B, executeShellInE2B: vi.fn(), + SIM_RESULT_PREFIX: '__SIM_RESULT__=', })) vi.mock('@/lib/copilot/request/tools/files', () => ({ @@ -81,7 +86,9 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, getWorkspaceFile: mockGetWorkspaceFile, + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, updateWorkspaceFileContent: mockUpdateWorkspaceFileContent, uploadWorkspaceFile: vi.fn(), })) @@ -138,6 +145,8 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) + mockResolveWorkspaceFileReference.mockResolvedValue(null) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, vfsPath: target.path, @@ -530,6 +539,152 @@ describe('Function Execute API Route', () => { expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() }) + it('rejects sandboxPath outputs when the call would run in isolated-vm (E2B enabled, JS without imports)', async () => { + envFlagsMock.isE2bEnabled = true + + const req = createMockRequest('POST', { + code: 'return "content"', + language: 'javascript', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + expect(data.error).toContain('no sandbox filesystem') + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + expect(mockExecuteInE2B).not.toHaveBeenCalled() + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('rejects sandbox file mounts when the call would run in isolated-vm', async () => { + const req = createMockRequest('POST', { + code: 'return 1', + language: 'javascript', + workspaceId: 'workspace-1', + _sandboxFiles: [{ path: '/home/user/files/data.csv', content: 'a,b\n1,2' }], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + // E2B is disabled in this test, so the remediation must name that cause + // instead of suggesting python (which would also fail without E2B). + expect(data.error).toContain('E2B is not enabled') + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + }) + + it('flags an overwrite export whose bytes are identical to the current file content as unchanged', async () => { + envFlagsMock.isE2bEnabled = true + const staleContent = '# doc\nunchanged mounted content\n' + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': staleContent }, + }) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_doc', + name: 'doc.md', + size: Buffer.byteLength(staleContent, 'utf-8'), + key: 'workspace/doc.md', + }) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from(staleContent, 'utf-8')) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + // Idempotent overwrites (retries, unchanged regenerations) must not fail; + // the write proceeds and the receipt carries the loud unchanged signal so + // the model can tell its "new content" never reached the sandbox file. + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(data.output.result.unchanged).toBe(true) + expect(data.output.result.message).toContain('byte-identical to the previous version') + expect(data.output.result.message).toContain('/home/user/doc.md') + }) + + it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { + envFlagsMock.isE2bEnabled = true + const newContent = '# doc\nnew content\n' + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': newContent }, + }) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_doc', + name: 'doc.md', + size: 36728, + key: 'workspace/doc.md', + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + // Sizes differ, so the current content is never downloaded for comparison. + expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(data.output.result.size).toBe(Buffer.byteLength(newContent, 'utf-8')) + expect(data.output.result.previousSize).toBe(36728) + expect(data.output.result.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(data.output.result.unchanged).toBe(false) + expect(data.output.result.message).toContain('replaced 36728 bytes') + expect(data.output.result.message).toContain('sha256:') + // The python wrapper prints the marker with a leading \n so it always + // starts a fresh line even after non-newline-terminated user output. + const e2bCode = mockExecuteInE2B.mock.calls[0][0].code as string + expect(e2bCode).toContain("print('\\n__SIM_RESULT__=' + json.dumps(__sim_result__))") + }) + it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) @@ -799,6 +954,10 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) + expect(mockExecuteInIsolatedVM).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 10000 }), + expect.any(Object) + ) }) it.concurrent('should handle empty parameters object', async () => { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 33ab418dac5..20568fd2010 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { functionExecuteContract } from '@/lib/api/contracts' @@ -18,7 +19,7 @@ import { import { isE2bEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' +import { executeInE2B, executeShellInE2B, SIM_RESULT_PREFIX } from '@/lib/execution/e2b' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' @@ -36,6 +37,10 @@ import { import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' +import { + fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' @@ -899,6 +904,49 @@ async function functionJsonResponse( ) } +/** + * Compares an about-to-be-exported buffer against the overwrite target's + * current content. `identical: true` means the export is a byte-for-byte no-op: + * either a legitimately idempotent regeneration, or the incident signature of + * code that never wrote to the declared sandboxPath (the file still holds the + * mounted input). Only the model can tell those apart, so callers surface the + * fact loudly in the receipt instead of failing the write. Comparison failures + * never block the write; the current content is only downloaded when the sizes + * already match. + */ +async function checkOverwriteTarget( + workspaceId: string, + targetPath: string, + buffer: Buffer +): Promise<{ previousSize?: number; identical: boolean }> { + try { + const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) + if (!existing) return { identical: false } + if (existing.size !== buffer.length) { + return { previousSize: existing.size, identical: false } + } + const current = await fetchWorkspaceFileBuffer(existing) + return { previousSize: existing.size, identical: current.equals(buffer) } + } catch { + return { identical: false } + } +} + +function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) { + return `(${bytes} bytes${ + previousSize !== undefined ? `, replaced ${previousSize} bytes` : '' + }, sha256:${sha256.slice(0, 16)})` +} + +function exportUnchangedNote(sandboxPath?: string): string { + return ( + 'WARNING: content is byte-identical to the previous version — nothing changed.' + + (sandboxPath + ? ` If you expected new content, your code did not modify the sandbox file at "${sandboxPath}" (it still holds the mounted input); write the new content to exactly that path and export again.` + : ' If you expected new content, the code returned the same bytes as before.') + ) +} + async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string @@ -982,7 +1030,16 @@ async function maybeExportSandboxFileToWorkspace(args: { const targetPath = overwriteFileId || outputPath const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') + let previousSize: number | undefined + let unchanged = false + if (mode === 'overwrite') { + const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) + previousSize = check.previousSize + unchanged = check.identical + } + try { + const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: authUserId, @@ -1001,17 +1058,28 @@ async function maybeExportSandboxFileToWorkspace(args: { mode, mimeType: resolvedMimeType, size: fileBuffer.length, + previousSize, + sha256, + unchanged, }) return NextResponse.json({ success: true, output: { result: { - message: `Sandbox file exported to ${written.vfsPath}`, + message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt( + fileBuffer.length, + previousSize, + sha256 + )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`, fileId: written.id, fileName: written.name, vfsPath: written.vfsPath, downloadUrl: written.downloadUrl, sandboxPath: outputSandboxPath, + size: fileBuffer.length, + previousSize, + sha256, + unchanged, }, stdout: cleanStdout(stdout), executionTime, @@ -1200,6 +1268,14 @@ async function maybeExportSandboxFilesToWorkspace(args: { const buffer = prepared.isBinary ? Buffer.from(prepared.content, 'base64') : Buffer.from(prepared.content, 'utf-8') + let previousSize: number | undefined + let unchanged = false + if (prepared.target.mode === 'overwrite') { + const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) + previousSize = check.previousSize + unchanged = check.identical + } + const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: args.authUserId, @@ -1214,8 +1290,18 @@ async function maybeExportSandboxFilesToWorkspace(args: { mode: prepared.file.mode ?? 'create', mimeType: prepared.resolvedMimeType, size: prepared.size, + previousSize, + sha256, + unchanged, + }) + writtenFiles.push({ + ...written, + sandboxPath: prepared.sandboxPath, + exportedBytes: buffer.length, + previousSize, + sha256, + unchanged, }) - writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath }) } } catch (error) { return NextResponse.json( @@ -1232,11 +1318,27 @@ async function maybeExportSandboxFilesToWorkspace(args: { ) } + const unchangedFiles = writtenFiles.filter((file) => file.unchanged) return NextResponse.json({ success: true, output: { result: { - message: `Exported ${writtenFiles.length} sandbox files`, + message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles + .map( + (file) => + `${file.vfsPath} ${formatExportReceipt( + file.exportedBytes, + file.previousSize, + file.sha256 + )}${file.unchanged ? ' [UNCHANGED]' : ''}` + ) + .join('; ')}${ + unchangedFiles.length > 0 + ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${ + unchangedFiles.length === 1 ? 'is' : 'are' + } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.` + : '' + }`, files: writtenFiles.map((file) => ({ fileId: file.id, fileName: file.name, @@ -1244,6 +1346,10 @@ async function maybeExportSandboxFilesToWorkspace(args: { backingVfsPath: file.backingVfsPath, downloadUrl: file.downloadUrl, sandboxPath: file.sandboxPath, + size: file.exportedBytes, + previousSize: file.previousSize, + sha256: file.sha256, + unchanged: file.unchanged, })), }, stdout: cleanStdout(args.stdout), @@ -1506,6 +1612,29 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } + // Sandbox file mounts and sandboxPath exports only exist in the E2B + // runtime; isolated-vm has no filesystem. Silently dropping a declared + // sandbox input/output here produced "export succeeded" responses with + // zero bytes written, so refuse the call instead. The remediation depends + // on WHY this call runs in isolated-vm — "switch to python" is a dead end + // when E2B is disabled or the call is a custom tool. + if (!useE2B && (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length)) { + const remediation = !isE2bEnabled + ? "E2B is not enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)." + : isCustomTool + ? "custom tools always run in the isolated JavaScript VM, which has no sandbox filesystem. Pass input data via params and return output as the code's return value." + : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the E2B sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.' + return functionJsonResponse( + { + success: false, + error: `Sandbox file inputs/outputs are unavailable for this call: ${remediation}`, + output: { result: null, stdout: '', executionTime: Date.now() - startTime }, + }, + routeContext, + { status: 422 } + ) + } + if (useE2B) { logger.info(`[${requestId}] E2B status`, { enabled: isE2bEnabled, @@ -1543,7 +1672,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ' const __sim_result = await (async () => {', ` ${codeBody.split('\n').join('\n ')}`, ' })();', - " console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));", + // Leading \n guarantees the marker starts a fresh line even when user + // code's last stdout write was not newline-terminated (chunks are + // concatenated verbatim on the parse side, so a glued marker would + // otherwise be missed silently). + ` console.log('\\n${SIM_RESULT_PREFIX}' + JSON.stringify(__sim_result));`, ' } catch (error) {', ' console.log(String((error && (error.stack || error.message)) || error));', ' throw error;', @@ -1635,7 +1768,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { 'def __sim_main__():', ...resolvedCode.split('\n').map((l) => ` ${l}`), '__sim_result__ = __sim_main__()', - "print('__SIM_RESULT__=' + json.dumps(__sim_result__))", + // Leading \n: same fresh-line guarantee as the JS wrapper's marker. + `print('\\n${SIM_RESULT_PREFIX}' + json.dumps(__sim_result__))`, ].join('\n') const codeForE2B = prologue + wrapped diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index cd8ec829cae..a67cb62ba37 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -251,7 +251,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, @@ -307,7 +307,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, @@ -361,7 +361,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1') fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ output: { ok: true } }), { @@ -411,7 +411,9 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: workflowWorkspaceId }]) + .mockResolvedValueOnce([ + { workspaceId: workflowWorkspaceId, deploymentVersionId: 'deployment-1' }, + ]) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -442,7 +444,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) mockResolveBillingAttribution.mockResolvedValueOnce( createBillingAttribution('different-actor', 'ws-1') ) @@ -565,7 +567,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( new ReadableStream({ @@ -609,7 +611,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( new ReadableStream({ @@ -656,7 +658,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -689,6 +691,9 @@ describe('MCP Serve Route', () => { const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit const headers = fetchOptions.headers as Record expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') + expect(JSON.parse(fetchOptions.body as string)).toMatchObject({ + deploymentVersionId: 'deployment-1', + }) }) it('preserves downstream attributed usage admission rejections', async () => { @@ -703,7 +708,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -747,7 +752,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { @@ -780,7 +785,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ success: true, output: false }), { status: 200, @@ -817,7 +822,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200, @@ -854,7 +859,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, userId: 'user-1', @@ -934,7 +939,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockImplementationOnce((_url, init: RequestInit) => { const signal = init.signal as AbortSignal return new Promise((_resolve, reject) => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index a5650de1042..e342f35e343 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -18,7 +18,13 @@ import { type Tool, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db/schema' +import { + workflow, + workflowDeploymentVersion, + workflowMcpServer, + workflowMcpTool, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -751,14 +757,30 @@ async function handleToolsCall( } const [wf] = await db - .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId }) + .select({ + workspaceId: workflow.workspaceId, + deploymentVersionId: workflowDeploymentVersion.id, + }) .from(workflow) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) .where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt))) .limit(1) const abortedAfterWorkflowLookup = callerAbortedJsonRpcResponse(id, abortSignal) if (abortedAfterWorkflowLookup) return abortedAfterWorkflowLookup - if (!wf?.isDeployed) { + /** + * Deployed means an active version snapshot exists — the legacy + * `workflow.isDeployed` flag is not consulted because when the two + * disagree the workflow cannot serve traffic anyway. Same definition as + * the deploy status GET route. + */ + if (!wf?.deploymentVersionId) { return NextResponse.json( createError(id, ErrorCode.InternalError, 'Workflow is not deployed'), { @@ -813,6 +835,7 @@ async function handleToolsCall( input: params.arguments || {}, triggerType: 'mcp', includeFileBase64: false, + ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}), }) assertKnownSizeWithinLimit( Buffer.byteLength(workflowRequestBody, 'utf-8'), 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 new file mode 100644 index 00000000000..15860b3e6e0 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -0,0 +1,485 @@ +/** + * @vitest-environment node + */ +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockTransaction, + mockSelectRows, + mockFilterForkableChatFiles, + mockListForkableChatFiles, + mockPlanChatFileCopies, + mockExecuteChatFileBlobCopies, + mockLoadCopilotChatMessages, + mockAppendCopilotChatMessages, + mockAssertActiveWorkspaceAccess, + mockFetchGo, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockDeleteWhere, + mockRemoveChatResources, +} = vi.hoisted(() => ({ + mockTransaction: vi.fn(), + mockSelectRows: vi.fn(), + // Real (pure) cut semantics so tests drive selection through row.messageId: + // rows with a NULL/undefined messageId are kept in every fork. + mockFilterForkableChatFiles: vi.fn( + (rows: Array<{ messageId?: string | null }>, kept: ReadonlySet) => + rows.filter((row) => !row.messageId || kept.has(row.messageId)) + ), + mockListForkableChatFiles: vi.fn(), + mockPlanChatFileCopies: vi.fn(), + mockExecuteChatFileBlobCopies: vi.fn(), + mockLoadCopilotChatMessages: vi.fn(), + mockAppendCopilotChatMessages: vi.fn(), + mockAssertActiveWorkspaceAccess: vi.fn(), + mockFetchGo: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockDeleteWhere: vi.fn(), + mockRemoveChatResources: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => mockSelectRows(), + }), + }), + }), + delete: () => ({ + where: mockDeleteWhere, + }), + transaction: mockTransaction, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + userId: 'copilotChats.userId', + type: 'copilotChats.type', + workspaceId: 'copilotChats.workspaceId', + title: 'copilotChats.title', + model: 'copilotChats.model', + resources: 'copilotChats.resources', + previewYaml: 'copilotChats.previewYaml', + planArtifact: 'copilotChats.planArtifact', + config: 'copilotChats.config', + }, + workspaceFiles: { + id: 'workspaceFiles.id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), +})) + +vi.mock('@/lib/copilot/resources/persistence', () => ({ + removeChatResources: mockRemoveChatResources, +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({ + filterForkableChatFiles: mockFilterForkableChatFiles, + listForkableChatFiles: mockListForkableChatFiles, + planChatFileCopies: mockPlanChatFileCopies, + executeChatFileBlobCopies: mockExecuteChatFileBlobCopies, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + loadCopilotChatMessages: mockLoadCopilotChatMessages, +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppendCopilotChatMessages, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/request/go/fetch', () => ({ + fetchGo: mockFetchGo, +})) + +vi.mock('@/lib/copilot/server/agent-url', () => ({ + getMothershipBaseURL: vi.fn().mockResolvedValue('http://mothership.test'), + getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), +})) + +vi.mock('@/lib/core/config/env', () => ({ env: {} })) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError: () => false, +})) + +import { POST } from '@/app/api/mothership/chats/[chatId]/fork/route' + +const OLD_FILE_ID = 'wf_oldfile' +const NEW_FILE_ID = 'wf_newfile' + +const parentRow = { + id: 'chat-1', + userId: 'user-1', + type: 'mothership', + workspaceId: 'ws-1', + title: 'Generate Logs', + model: 'claude-opus-4-8', + resources: [{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' }], + previewYaml: null, + planArtifact: null, + config: null, +} + +const threeMessages = [ + { + id: 'msg-1', + role: 'user', + content: `See ![cat](/api/files/view/${OLD_FILE_ID})`, + timestamp: '2026-07-01T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Nice cat.', + timestamp: '2026-07-01T00:00:01.000Z', + }, + { + id: 'msg-3', + role: 'user', + content: 'A later message the fork must not keep.', + timestamp: '2026-07-01T00:00:02.000Z', + }, +] + +/** Chat rows inserted through the mock transaction, captured for title assertions. */ +let insertedChatRows: Array> = [] +/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */ +let updatedChatRows: Array> = [] + +function makeTx() { + return { + insert: () => ({ + values: (v: Record) => { + insertedChatRows.push(v) + return { + returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }], + } + }, + }), + update: () => ({ + set: (v: Record) => { + updatedChatRows.push(v) + return { where: async () => undefined } + }, + }), + } +} + +function createRequest(chatId: string, body?: unknown) { + return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? { upToMessageId: 'msg-2' }), + }) +} + +function makeContext(chatId: string) { + return { params: Promise.resolve({ chatId }) } +} + +describe('POST /api/mothership/chats/[chatId]/fork', () => { + beforeEach(() => { + vi.clearAllMocks() + insertedChatRows = [] + updatedChatRows = [] + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + mockSelectRows.mockResolvedValue([parentRow]) + mockListForkableChatFiles.mockResolvedValue([]) + mockLoadCopilotChatMessages.mockResolvedValue(threeMessages) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map(), + keyMap: new Map(), + blobTasks: [], + }) + mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] }) + mockAppendCopilotChatMessages.mockResolvedValue(undefined) + mockDeleteWhere.mockResolvedValue(undefined) + mockRemoveChatResources.mockResolvedValue(undefined) + mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockFetchGo.mockResolvedValue({ ok: true }) + mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) => + cb(makeTx()) + ) + }) + + it('rejects unauthenticated callers', async () => { + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: null, + isAuthenticated: false, + }) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(401) + }) + + it('404s when the chat belongs to another user', async () => { + mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(404) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('404s for non-mothership chats', async () => { + mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(404) + }) + + it('400s when upToMessageId is missing', async () => { + const res = await POST(createRequest('chat-1', {}), makeContext('chat-1')) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('400s when upToMessageId is an empty string', async () => { + const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1')) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('400s when the message is not in the chat', async () => { + const res = await POST( + createRequest('chat-1', { upToMessageId: 'msg-unknown' }), + makeContext('chat-1') + ) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('applies the timeline cut: kept message ids drive the file selection', async () => { + // Two uploads born pre-cut, one born post-cut, and one legacy row with no + // birth message. The single chat-owned read is cut in memory: everything + // but the post-cut row. + const preCutUpload = { id: 'wf_up', size: 1, context: 'mothership', messageId: 'msg-1' } + const secondPreCut = { id: 'wf_up2', size: 1, context: 'mothership', messageId: 'msg-2' } + const postCutUpload = { id: 'wf_late', size: 1, context: 'mothership', messageId: 'msg-3' } + const legacyRow = { id: 'wf_legacy', size: 1, context: 'mothership', messageId: null } + mockListForkableChatFiles.mockResolvedValue([ + preCutUpload, + secondPreCut, + postCutUpload, + legacyRow, + ]) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(200) + + // The cut runs over the single read with the kept slice (inclusive of + // msg-2, excluding msg-3). + expect(mockListForkableChatFiles).toHaveBeenCalledTimes(1) + const filterCall = mockFilterForkableChatFiles.mock.calls[0] + expect(filterCall[1]).toEqual(new Set(['msg-1', 'msg-2'])) + + // The copy plan receives the cut set — the pre-cut uploads plus the + // legacy no-birthdate row; the post-cut upload stays behind. + expect(mockPlanChatFileCopies.mock.calls[0][0].rows).toEqual([ + preCutUpload, + secondPreCut, + legacyRow, + ]) + + // The appended transcript is the same inclusive slice. + const appended = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[1].map((m: { id: string }) => m.id)).toEqual(['msg-1', 'msg-2']) + }) + + it('forks the chat: copies kept uploads, rewrites references, clones agent state', async () => { + const blobTasks = [ + { + sourceKey: 'workspace/ws-1/old-cat.png', + targetKey: 'workspace/ws-1/new-cat.png', + context: 'mothership', + fileName: 'cat.png', + contentType: 'image/png', + }, + ] + mockListForkableChatFiles.mockResolvedValue([ + { size: 100, messageId: 'msg-1', workspaceId: 'ws-1' }, + ]) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map([[OLD_FILE_ID, NEW_FILE_ID]]), + keyMap: new Map([['workspace/ws-1/old-cat.png', 'workspace/ws-1/new-cat.png']]), + blobTasks, + }) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.success).toBe(true) + expect(typeof body.id).toBe('string') + + // The real rewriter runs: the kept message's view-URL points at the copy. + const appended = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toBe(body.id) + expect(appended[1][0].content).toBe(`See ![cat](/api/files/view/${NEW_FILE_ID})`) + + expect(mockExecuteChatFileBlobCopies).toHaveBeenCalledWith(blobTasks) + + const goCall = mockFetchGo.mock.calls[0] + expect(goCall[0]).toBe('http://mothership.test/api/chats/fork') + const goBody = JSON.parse(goCall[1].body) + // The copilot service only knows USER message ids, so the clone cut is the + // kept slice's last user message (msg-1), not the clicked assistant (msg-2). + expect(goBody).toEqual({ + sourceChatId: 'chat-1', + newChatId: body.id, + upToMessageId: 'msg-1', + userId: 'user-1', + }) + + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: body.id, + type: 'created', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_forked', + { workspace_id: 'ws-1', source_chat_id: 'chat-1' }, + { groups: { workspace: 'ws-1' } } + ) + + // Forks are titled "Fork | ". + expect(insertedChatRows[0].title).toBe('Fork | Generate Logs') + }) + + it('still succeeds when the copilot-service clone fails (best-effort)', async () => { + mockFetchGo.mockRejectedValue(new Error('mothership unreachable')) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(200) + }) + + it('surfaces failed blob copies and cleans up their dead rows + resource chips', async () => { + mockExecuteChatFileBlobCopies.mockResolvedValue({ + copied: 1, + failed: 2, + failedCopyIds: ['wf_dead1', 'wf_dead2'], + }) + + const failedRes = await POST(createRequest('chat-1'), makeContext('chat-1')) + const body = await failedRes.json() + + expect(body.failedFileCopies).toBe(2) + + // The dead rows (committed, but no bytes behind them) are hard-deleted so + // they vanish from VFS listings and name resolution… + expect(mockDeleteWhere).toHaveBeenCalledWith({ + type: 'inArray', + field: 'workspaceFiles.id', + values: ['wf_dead1', 'wf_dead2'], + }) + // …and their resource chips are dropped from the new chat. + expect(mockRemoveChatResources).toHaveBeenCalledWith(body.id, [ + { type: 'file', id: 'wf_dead1', title: '' }, + { type: 'file', id: 'wf_dead2', title: '' }, + ]) + }) + + it('omits failedFileCopies and skips cleanup when every blob copies', async () => { + mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 3, failed: 0, failedCopyIds: [] }) + + const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect('failedFileCopies' in (await cleanRes.json())).toBe(false) + expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(mockRemoveChatResources).not.toHaveBeenCalled() + }) + + it('copies pre-cut uploads and drops only post-cut ghosts', async () => { + // The source chat owns two more uploads (apple pre-cut, banana post-cut) + // beside the kept one, plus one shared workspace-file resource. The fork + // copies the kept upload AND the pre-cut apple; only the post-cut banana + // stays behind, so only its resource is dropped — not left pointing at + // the source chat. + mockSelectRows.mockResolvedValue([ + { + ...parentRow, + resources: [ + { type: 'file', id: OLD_FILE_ID, title: 'cat.png' }, + { type: 'file', id: 'wf_apple', title: 'apple.png' }, + { type: 'file', id: 'wf_banana', title: 'banana.png' }, + { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, + { type: 'workflow', id: 'wflow-1', title: 'My flow' }, + ], + }, + ]) + // Every chat-owned file of the source chat in the single read; messageId + // drives the in-memory cut. + mockListForkableChatFiles.mockResolvedValue([ + { id: OLD_FILE_ID, size: 100, context: 'mothership', messageId: 'msg-1' }, + { id: 'wf_apple', size: 50, context: 'mothership', messageId: 'msg-1' }, + { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, + ]) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map([ + [OLD_FILE_ID, NEW_FILE_ID], + ['wf_apple', 'wf_apple_copy'], + ]), + keyMap: new Map(), + blobTasks: [], + }) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(200) + // The plan received the cut set: the kept upload + pre-cut apple only. + expect(mockPlanChatFileCopies.mock.calls[0][0].rows.map((r: { id: string }) => r.id)).toEqual([ + OLD_FILE_ID, + 'wf_apple', + ]) + expect(updatedChatRows).toHaveLength(1) + expect(updatedChatRows[0].resources).toEqual([ + { type: 'file', id: NEW_FILE_ID, title: 'cat.png' }, + { type: 'file', id: 'wf_apple_copy', title: 'apple.png' }, + { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, + { type: 'workflow', id: 'wflow-1', title: 'My flow' }, + ]) + }) + + it('drops ghosts even when the fork copies no files at all', async () => { + // Fork cut before the chat's only upload arrived: a guard that skips the + // resources update when idMap is empty would leave the ghost in place. + mockSelectRows.mockResolvedValue([ + { + ...parentRow, + resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }], + }, + ]) + mockListForkableChatFiles.mockResolvedValue([ + { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, + ]) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(200) + expect(updatedChatRows).toHaveLength(1) + expect(updatedChatRows[0].resources).toEqual([]) + }) +}) 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 44b63c7f163..9f8011bd264 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -1,13 +1,23 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { copilotChats, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { + executeChatFileBlobCopies, + filterForkableChatFiles, + listForkableChatFiles, + planChatFileCopies, +} from '@/lib/copilot/chat/fork-chat-files' import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { + rewriteMessageFileRefs, + rewriteResourceFileRefs, +} from '@/lib/copilot/chat/rewrite-file-references' import { chatPubSub } from '@/lib/copilot/chat-status' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { @@ -18,6 +28,7 @@ import { createNotFoundResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { removeChatResources } from '@/lib/copilot/resources/persistence' import type { MothershipResource } from '@/lib/copilot/resources/types' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' @@ -33,7 +44,16 @@ const logger = createLogger('ForkChatAPI') /** * POST /api/mothership/chats/[chatId]/fork * Creates a new chat branched from the given chat, keeping messages up to and - * including the specified message. Resources and copilot-side state are copied. + * including the specified message, along with the chat's uploads born + * at-or-before the fork point (a file travels iff the user message that + * carried it is kept). Resources and copilot-side state are copied. + * + * Every copied file gets a fresh row id and storage key, and every + * in-transcript file reference is re-pointed at the copies so the new chat + * survives deletion of the source chat. Mothership files remain excluded from + * workspace storage accounting. File resources whose chat-owned file was NOT + * copied (uploads born after the cut) are dropped from the new chat's resources + * rather than left as ghosts pointing at the source chat's files. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { @@ -82,17 +102,36 @@ export const POST = withRouteHandler( } const forkedMessages = messages.slice(0, forkIdx + 1) - // Resources are stored as a jsonb array on the chat row — copy them directly. + // Single workspace_files read per fork: every chat-owned upload. The + // copied set is timeline-cut to the kept message slice in memory (files + // born after the fork point stay behind). + const chatOwnedFiles = await listForkableChatFiles(db, chatId) + const sourceFiles = filterForkableChatFiles( + chatOwnedFiles, + new Set(forkedMessages.map((m) => m.id)) + ) + + // Resources are stored as a jsonb array on the chat row. They carry no + // timestamps, so they can't be timeline-cut like messages — instead, + // file resources whose chat-owned file is NOT copied (uploads born + // after the cut) are dropped in the rewrite below; everything else is + // copied. const parentResources = Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : [] + // The source chat's chat-owned file ids (no cut) — the "is this + // resource a ghost?" test set for the rewrite. + const chatOwnedFileIds = new Set(chatOwnedFiles.map((row) => row.id)) + const newId = generateId() + // Strip a leading "Fork | " so titles don't stack prefixes when forking + // a forked chat. const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '') const title = `Fork | ${baseTitle}` const now = new Date() - const newChat = await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const [row] = await tx .insert(copilotChats) .values({ @@ -114,16 +153,82 @@ export const POST = withRouteHandler( if (!row) return null - await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx) - return row + // File rows FK the new chat row, so the plan runs after the insert. + const { idMap, keyMap, blobTasks } = await planChatFileCopies({ + tx, + rows: sourceFiles, + newChatId: newId, + userId, + now, + }) + + const maps = { fileIds: idMap, fileKeys: keyMap } + const newChatResources = rewriteResourceFileRefs(parentResources, maps, chatOwnedFileIds) + // Skip the redundant update only when the rewrite changed nothing: + // no ids re-pointed AND no ghost resources dropped. (idMap and keyMap + // are populated in lockstep, so idMap alone decides the first half.) + if (idMap.size > 0 || newChatResources.length !== parentResources.length) { + await tx + .update(copilotChats) + .set({ resources: newChatResources }) + .where(eq(copilotChats.id, newId)) + } + + await appendCopilotChatMessages( + newId, + rewriteMessageFileRefs(forkedMessages, maps), + { chatModel: parent.model }, + tx + ) + return { row, blobTasks } }) - if (!newChat) { + if (!result) { return createInternalServerErrorResponse('Failed to create forked chat') } + const newChat = result.row + + const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies(result.blobTasks) + if (failed > 0) { + // A failed blob copy leaves a committed row with no bytes behind it. + // Cleanly absent beats present-but-broken: hard-delete the dead rows + // (they vanish from the VFS listings and name resolution) and drop + // their resource chips from the new chat. Inline transcript embeds + // can't be healed — those 404 — which is what `failedFileCopies` in + // the response warns the user about. + try { + await db.delete(workspaceFiles).where(inArray(workspaceFiles.id, failedCopyIds)) + await removeChatResources( + newId, + failedCopyIds.map((id) => ({ type: 'file' as const, id, title: '' })) + ) + } catch (cleanupError) { + logger.error('Failed to clean up dead file rows after blob-copy failure', { + newChatId: newId, + failedCopyIds, + error: cleanupError, + }) + } + logger.warn('Some chat file blobs failed to copy during fork', { + chatId, + newChatId: newId, + copied, + failed, + }) + } // Clone copilot-service conversation state (messages, active_messages, memory files). // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. + // The service stamps MessageID only on USER messages (assistant rows carry + // Sim-local ids it has never seen), so hand it the kept slice's last user + // message — it clones through the end of that turn, matching this route's cut. + let goCutMessageId = upToMessageId + for (let i = forkedMessages.length - 1; i >= 0; i--) { + if (forkedMessages[i].role === 'user') { + goCutMessageId = forkedMessages[i].id + break + } + } try { const copilotHeaders: Record = { 'Content-Type': 'application/json' } if (env.COPILOT_API_KEY) { @@ -137,7 +242,7 @@ export const POST = withRouteHandler( body: JSON.stringify({ sourceChatId: chatId, newChatId: newId, - upToMessageId, + upToMessageId: goCutMessageId, userId, }), spanName: 'sim → go /api/chats/fork', @@ -168,7 +273,11 @@ export const POST = withRouteHandler( { groups: { workspace: parent.workspaceId ?? '' } } ) - return NextResponse.json({ success: true, id: newId }) + return NextResponse.json({ + success: true, + id: newId, + ...(failed > 0 ? { failedFileCopies: failed } : {}), + }) } catch (error) { if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 55c2f7cceaa..62eb7bc22ed 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -14,12 +14,12 @@ import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, } from '@/lib/copilot/generated/mothership-stream-v1' +import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildUserSkillTool } from '@/lib/mothership/skills' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -107,6 +107,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { requestId: providedRequestId, fileAttachments, contexts, + mcpTools, workflowId, executionId, userMetadata, @@ -147,21 +148,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined + const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) => + context.kind === 'mcp' && context.serverId ? [context.serverId] : [] + ) + const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') const [ workspaceContext, integrationTools, - userSkillTool, + mothershipTools, userPermission, entitlements, agentContexts, ] = await Promise.all([ generateWorkspaceContext(workspaceId, userId), buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - buildUserSkillTool(workspaceId), + Promise.all([ + buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), + buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), + ]).then((groups) => { + const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) + return [...byName.values()] + }), getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), computeWorkspaceEntitlements(workspaceId, userId), processContextsServer( - agentMentions, + nonMcpAgentMentions, userId, lastUserMessage, workspaceId, @@ -191,9 +202,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(userMetadata ? { userMetadata } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), - ...(agentContexts.length > 0 ? { contexts: agentContexts } : {}), + ...(agentContexts.length > 0 || mothershipTools.length > 0 + ? { + contexts: [ + ...agentContexts, + ...(mothershipTools.length > 0 + ? [ + { + type: 'mcp', + content: [ + 'The following MCP tools are explicitly enabled for this request.', + 'Load one with load_custom_tool({ type: "mcp", name: "" }) before calling it.', + 'Do not narrate discovery, loading, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', + ...mothershipTools.map( + (tool) => `- ${tool.name}: ${tool.description || tool.name}` + ), + ].join('\n'), + }, + ] + : []), + ], + } + : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), + ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(userPermission ? { userPermission } : {}), ...(entitlements.length > 0 ? { entitlements } : {}), } diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 63a9258211d..13f87274810 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -165,6 +165,7 @@ async function claimWorkflowSchedules(queuedAt: Date, limit: number) { lastQueuedAt: workflowSchedule.lastQueuedAt, timezone: workflowSchedule.timezone, deploymentVersionId: workflowSchedule.deploymentVersionId, + deploymentOperationId: workflowSchedule.deploymentOperationId, sourceType: workflowSchedule.sourceType, }) @@ -864,6 +865,7 @@ async function processScheduleItem( workspaceId, billingAttribution, deploymentVersionId: schedule.deploymentVersionId || undefined, + deploymentOperationId: schedule.deploymentOperationId || undefined, cronExpression: schedule.cronExpression || undefined, timezone: schedule.timezone || undefined, lastRanAt: schedule.lastRanAt?.toISOString(), diff --git a/apps/sim/app/api/tools/clickup/folders/route.ts b/apps/sim/app/api/tools/clickup/folders/route.ts new file mode 100644 index 00000000000..6613be9189e --- /dev/null +++ b/apps/sim/app/api/tools/clickup/folders/route.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupFoldersSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpFoldersAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, spaceId } = parsed.data.body + + const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId') + if (!spaceIdValidation.isValid) { + logger.error('Invalid spaceId', { error: spaceIdValidation.error }) + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp folders', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp folders' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + folders?: ClickUpNamedResource[] + } + const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({ + id: String(item.id), + name: item.name || `Folder ${item.id}`, + })) + + return NextResponse.json({ folders }) + } catch (error) { + logger.error('Error fetching ClickUp folders', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp folders' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/clickup/lists/route.ts b/apps/sim/app/api/tools/clickup/lists/route.ts new file mode 100644 index 00000000000..a07712b36e4 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/lists/route.ts @@ -0,0 +1,105 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupListsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpListsAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, folderId, spaceId } = parsed.data.body + + if (folderId?.trim()) { + const folderIdValidation = validateAlphanumericId(folderId.trim(), 'folderId') + if (!folderIdValidation.isValid) { + logger.error('Invalid folderId', { error: folderIdValidation.error }) + return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) + } + } + + if (spaceId?.trim()) { + const spaceIdValidation = validateAlphanumericId(spaceId.trim(), 'spaceId') + if (!spaceIdValidation.isValid) { + logger.error('Invalid spaceId', { error: spaceIdValidation.error }) + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } + } + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + folderId?.trim() + ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list` + : `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp lists' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + lists?: ClickUpNamedResource[] + } + const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({ + id: String(item.id), + name: item.name || `List ${item.id}`, + })) + + return NextResponse.json({ lists }) + } catch (error) { + logger.error('Error fetching ClickUp lists', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp lists' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/clickup/spaces/route.ts b/apps/sim/app/api/tools/clickup/spaces/route.ts new file mode 100644 index 00000000000..197e5245ca4 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/spaces/route.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupSpacesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpSpacesAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, teamId } = parsed.data.body + + const teamIdValidation = validateAlphanumericId(teamId, 'teamId') + if (!teamIdValidation.isValid) { + logger.error('Invalid teamId', { error: teamIdValidation.error }) + return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp spaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp spaces' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + spaces?: ClickUpNamedResource[] + } + const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({ + id: String(item.id), + name: item.name || `Space ${item.id}`, + })) + + return NextResponse.json({ spaces }) + } catch (error) { + logger.error('Error fetching ClickUp spaces', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp spaces' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/clickup/upload-attachment/route.ts b/apps/sim/app/api/tools/clickup/upload-attachment/route.ts new file mode 100644 index 00000000000..bcb4d8ab435 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/upload-attachment/route.ts @@ -0,0 +1,131 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupUploadAttachmentContract } from '@/lib/api/contracts/tools/clickup' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpAttachment, +} from '@/tools/clickup/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ClickUpUploadAttachmentAPI') + +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 + +function uploadSizeError(bytes: number): NextResponse { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { success: false, error: `File size (${sizeMB}MB) exceeds upload limit of 100MB` }, + { status: 400 } + ) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(clickupUploadAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const userFiles = processFilesToUserFiles([params.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json( + { success: false, error: 'No valid file provided for upload' }, + { status: 400 } + ) + } + + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + if (userFile.size > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(userFile.size) + } + + let buffer: Buffer + let downloadedContentType = '' + try { + const result = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_UPLOAD_SIZE_BYTES, + }) + buffer = result.buffer + downloadedContentType = result.contentType + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + if (error instanceof PayloadSizeLimitError) { + return uploadSizeError(error.observedBytes ?? userFile.size) + } + throw error + } + + if (buffer.length > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(buffer.length) + } + + const formData = new FormData() + const blob = new Blob([new Uint8Array(buffer)], { + type: downloadedContentType || userFile.type || 'application/octet-stream', + }) + formData.append('attachment', blob, userFile.name) + + const url = `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/attachment` + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: clickupAuthorizationHeader(params.accessToken), + }, + body: formData, + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + const message = extractClickUpErrorMessage( + response, + data, + 'Failed to upload ClickUp attachment' + ) + logger.error(`[${requestId}] ClickUp attachment upload failed`, { + status: response.status, + message, + }) + return NextResponse.json({ success: false, error: message }, { status: response.status }) + } + + return NextResponse.json({ + success: true, + output: { + attachment: mapClickUpAttachment(data), + files: userFiles, + }, + }) + } catch (error) { + logger.error(`[${requestId}] ClickUp attachment upload error`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Internal server error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickup/workspaces/route.ts b/apps/sim/app/api/tools/clickup/workspaces/route.ts new file mode 100644 index 00000000000..2ff80e7f2dd --- /dev/null +++ b/apps/sim/app/api/tools/clickup/workspaces/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpWorkspacesAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpTeam { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp workspaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp workspaces' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] } + const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({ + id: String(team.id), + name: team.name || `Workspace ${team.id}`, + })) + + return NextResponse.json({ workspaces }) + } catch (error) { + logger.error('Error fetching ClickUp workspaces', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp workspaces' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index bf0ea376a84..5f35c91da86 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -51,11 +51,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const result = await performFullDeploy({ workflowId, userId: auth.userId, - workflowName: access.workflow.name || undefined, versionName: name, versionDescription: description ?? undefined, requestId, - request, }) if (!result.success) { @@ -69,9 +67,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: true, output: { workflowId, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version: result.version, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings ?? [], }, }) diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index 406b08b0b9d..a126c3dbd57 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -53,9 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowId, version, userId: auth.userId, - workflow: access.workflow as Record, requestId, - request, }) if (!result.success) { @@ -69,9 +67,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: true, output: { workflowId, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings ?? [], }, }) diff --git a/apps/sim/app/api/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts index 63698c3a8b2..f0313a779b3 100644 --- a/apps/sim/app/api/tools/deployments/routes.test.ts +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -90,6 +90,11 @@ describe('POST /api/tools/deployments/deploy', () => { success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), version: 4, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, }) }) @@ -157,6 +162,11 @@ describe('POST /api/tools/deployments/deploy', () => { isDeployed: true, deployedAt: '2026-06-12T00:00:00.000Z', version: 4, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, warnings: [], }, }) @@ -236,6 +246,11 @@ describe('POST /api/tools/deployments/promote', () => { mockPerformActivateVersion.mockResolvedValue({ success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-06-12T00:00:00.000Z', + }, }) }) @@ -255,6 +270,11 @@ describe('POST /api/tools/deployments/promote', () => { isDeployed: true, deployedAt: '2026-06-12T00:00:00.000Z', version: 3, + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-06-12T00:00:00.000Z', + }, warnings: [], }) }) diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts index bb6a05cea88..9249cb76e69 100644 --- a/apps/sim/app/api/tools/pipedrive/get-files/route.ts +++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts @@ -11,6 +11,7 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' export const dynamic = 'force-dynamic' @@ -22,6 +23,20 @@ interface PipedriveFile { url?: string } +/** + * Whether a download URL belongs to Pipedrive. The workspace credential is + * only attached to Pipedrive-owned hosts — if the API ever returns an + * external/CDN download URL, it is fetched without credentials. + */ +function isPipedriveHost(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase() + return hostname === 'pipedrive.com' || hostname.endsWith('.pipedrive.com') + } catch { + return false + } +} + interface PipedriveApiResponse { success: boolean data?: PipedriveFile[] @@ -54,7 +69,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(pipedriveGetFilesContract, request, {}) if (!parsed.success) return parsed.response - const { accessToken, sort, limit, start, downloadFiles } = parsed.data.body + const { accessToken, authStyle, sort, limit, start, downloadFiles } = parsed.data.body + const authHeaders = getPipedriveAuthHeaders({ accessToken, authStyle }) const baseUrl = 'https://api.pipedrive.com/v1/files' const queryParams = new URLSearchParams() @@ -75,10 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const response = await secureFetchWithPinnedIP(apiUrl, urlValidation.resolvedIP!, { method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, + headers: authHeaders, }) const data = (await response.json()) as PipedriveApiResponse @@ -102,6 +115,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }> = [] if (downloadFiles) { + // Bare auth headers for byte downloads — no Accept: application/json. + const downloadAuthHeaders: Record = + authStyle === 'x-api-token' + ? { 'x-api-token': accessToken } + : { Authorization: `Bearer ${accessToken}` } + for (const file of files) { if (!file?.url) continue @@ -114,7 +133,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileUrlValidation.resolvedIP!, { method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, + headers: isPipedriveHost(file.url) ? downloadAuthHeaders : {}, } ) diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts index 707a9ff9ee4..16381901228 100644 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -5,7 +5,8 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { resolveCredentialAccessToken } from '@/app/api/auth/oauth/utils' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' const logger = createLogger('PipedrivePipelinesAPI') @@ -36,7 +37,9 @@ interface PipedrivePipelinesPage { * `PIPEDRIVE_MAX_PIPELINES_PAGES`; logs a warning rather than silently dropping * pipelines when the cap is hit. */ -async function fetchAllPipelines(accessToken: string): Promise { +async function fetchAllPipelines( + authHeaders: Record +): Promise { const pipelines: PipedrivePipeline[] = [] let start = 0 @@ -46,10 +49,7 @@ async function fetchAllPipelines(accessToken: string): Promise { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accessToken = await refreshAccessTokenIfNeeded( + const tokenResult = await resolveCredentialAccessToken( credential, authz.credentialOwnerUserId, requestId ) - if (!accessToken) { + if (!tokenResult) { logger.error('Failed to get access token', { credentialId: credential, userId: authz.credentialOwnerUserId, @@ -124,7 +124,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let allPipelines: PipedrivePipeline[] try { - allPipelines = await fetchAllPipelines(accessToken) + allPipelines = await fetchAllPipelines(getPipedriveAuthHeaders(tokenResult)) } catch (error) { if (error instanceof PipedriveFetchError) { logger.error('Failed to fetch Pipedrive pipelines', { diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 04f61a8a964..4754de31054 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -629,18 +629,6 @@ export interface AdminDeploymentVersion { deployedByName: string | null } -export interface AdminDeployResult { - isDeployed: boolean - version: number - deployedAt: string - warnings?: string[] -} - -export interface AdminUndeployResult { - isDeployed: boolean - warnings?: string[] -} - // ============================================================================= // Audit Log Types // ============================================================================= diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 62fe6405bfd..5e844a00188 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { + type AdminV1DeployResult, + type AdminV1UndeployResult, adminV1DeployWorkflowContract, adminV1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/admin' @@ -15,7 +17,6 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowDeployAPI') export const maxDuration = 120 @@ -51,9 +52,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId, userId: workflowRecord.userId, - workflowName: workflowRecord.name, requestId, - request, actorId: 'admin-api', }) @@ -63,13 +62,19 @@ export const POST = withRouteHandler( return internalErrorResponse(result.error || 'Failed to deploy workflow') } - logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`) + const isDeployed = Boolean(result.activeDeployment) + const attemptActivated = result.latestDeploymentAttempt?.status === 'active' + logger.info( + `[${requestId}] Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}` + ) - const response: AdminDeployResult = { - isDeployed: true, - version: result.version!, - deployedAt: result.deployedAt!.toISOString(), + const response: AdminV1DeployResult = { + isDeployed, + version: result.version ?? null, + deployedAt: result.deployedAt?.toISOString() ?? null, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, } return singleResponse(response) @@ -108,7 +113,7 @@ export const DELETE = withRouteHandler( logger.info(`Admin API: Undeployed workflow ${workflowId}`) - const response: AdminUndeployResult = { + const response: AdminV1UndeployResult = { isDeployed: false, warnings: result.warnings, } diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 360a817c5b6..4f5922c3745 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -40,9 +40,7 @@ export const POST = withRouteHandler( workflowId, version: versionNum, userId: workflowRecord.userId, - workflow: workflowRecord as Record, requestId, - request, actorId: 'admin-api', }) @@ -53,14 +51,16 @@ export const POST = withRouteHandler( } logger.info( - `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` + `[${requestId}] Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}` ) return singleResponse({ success: true, version: versionNum, - deployedAt: result.deployedAt!.toISOString(), + deployedAt: result.deployedAt?.toISOString() ?? null, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error) { logger.error( diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index dd78899e2ac..42e977bdfe2 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -82,6 +82,22 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { deployedAt: new Date('2026-06-12T00:00:00Z'), version: 4, warnings: undefined, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, + latestDeploymentAttempt: { + id: 'op-1', + deploymentVersionId: 'dv-4', + version: 4, + action: 'deploy', + status: 'active', + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-06-12T00:00:00.000Z', + activatedAt: '2026-06-12T00:00:00.000Z', + error: null, + }, }) }) @@ -163,6 +179,8 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { deployedAt: '2026-06-12T00:00:00.000Z', version: 4, warnings: [], + activeDeployment: expect.objectContaining({ deploymentVersionId: 'dv-4', version: 4 }), + latestDeploymentAttempt: expect.objectContaining({ id: 'op-1', status: 'active' }), }) }) @@ -179,12 +197,11 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { versionDescription: 'Fixes the agent prompt', }) ) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'workflow_deployed', - expect.objectContaining({ workflow_id: WORKFLOW_ID }), - expect.anything() - ) + /** + * The workflow_deployed analytics event is emitted by the activation + * side effects in the deployment outbox, not by this route. + */ + expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) it('maps validation failures from the orchestration to 400', async () => { diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 008a45a9820..822e00ab2ca 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -60,11 +60,9 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId, - workflowName: workflow.name || undefined, versionName: body.data.name, versionDescription: body.data.description ?? undefined, requestId, - request, }) if (!result.success) { @@ -74,25 +72,19 @@ export const POST = withRouteHandler( ) } - captureServerEvent( - userId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) + const isDeployed = Boolean(result.activeDeployment) const limits = await getUserLimits(userId) const apiResponse = createApiResponse( { data: { id, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt?.toISOString() ?? null, version: result.version, warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }, limits, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index cdebe9e35af..8ee6ec2940b 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -81,6 +81,22 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { mockPerformActivateVersion.mockResolvedValue({ success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, + latestDeploymentAttempt: { + id: 'op-1', + deploymentVersionId: 'dv-4', + version: 4, + action: 'activate', + status: 'active', + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-06-12T00:00:00.000Z', + activatedAt: '2026-06-12T00:00:00.000Z', + error: null, + }, }) }) @@ -128,6 +144,8 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { deployedAt: '2026-06-12T00:00:00.000Z', version: 4, warnings: [], + activeDeployment: expect.objectContaining({ deploymentVersionId: 'dv-4', version: 4 }), + latestDeploymentAttempt: expect.objectContaining({ id: 'op-1', status: 'active' }), }) }) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index 534e7fd89de..63ca166cdf1 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -9,7 +9,6 @@ import { import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' @@ -81,9 +80,7 @@ export const POST = withRouteHandler( workflowId: id, version: targetVersion, userId, - workflow: workflow as Record, requestId, - request, }) if (!result.success) { @@ -93,22 +90,17 @@ export const POST = withRouteHandler( ) } - captureServerEvent( - userId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, - { groups: { workspace: workspaceId } } - ) - const limits = await getUserLimits(userId) const apiResponse = createApiResponse( { data: { id, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version: targetVersion, warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }, limits, diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 440636186e1..4c7e1027161 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -10,7 +10,11 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { + getWorkflowDeploymentSummary, + performFullDeploy, + performFullUndeploy, +} from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { @@ -44,7 +48,17 @@ export const GET = withRouteHandler( return createErrorResponse(error.message, error.status) } - if (!workflowData.isDeployed) { + /** + * A workflow is deployed only when an active version snapshot exists — + * the same definition POST and the v1 routes use. The legacy + * `workflow.isDeployed` flag is deliberately not consulted: when it + * disagrees with the version table the workflow cannot actually serve + * traffic, so reporting it as live would be untruthful. + */ + const deploymentSummary = await getWorkflowDeploymentSummary(id) + const isDeployed = deploymentSummary.activeDeployment !== null + + if (!isDeployed) { logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ isDeployed: false, @@ -52,10 +66,17 @@ export const GET = withRouteHandler( apiKey: null, needsRedeployment: false, isPublicApi: workflowData.isPublicApi ?? false, + activeDeployment: deploymentSummary.activeDeployment, + latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + warnings: deploymentSummary.warnings, }) } - const needsRedeployment = await checkNeedsRedeployment(id) + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + attemptStatus === 'preparing' || attemptStatus === 'activating' + ? false + : await checkNeedsRedeployment(id) logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) @@ -65,10 +86,13 @@ export const GET = withRouteHandler( return createSuccessResponse({ apiKey: responseApiKeyInfo, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt, + isDeployed, + deployedAt: deploymentSummary.activeDeployment?.deployedAt ?? workflowData.deployedAt, needsRedeployment, isPublicApi: workflowData.isPublicApi ?? false, + activeDeployment: deploymentSummary.activeDeployment, + latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + warnings: deploymentSummary.warnings, }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) @@ -102,9 +126,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId: actorUserId, - workflowName: workflowData!.name || undefined, requestId, - request, }) if (!result.success) { @@ -114,16 +136,10 @@ export const POST = withRouteHandler( ) } - logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - - captureServerEvent( - actorUserId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' }, - { - groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } + const isDeployed = Boolean(result.activeDeployment) + const attemptActivated = result.latestDeploymentAttempt?.status === 'active' + logger.info( + `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` ) const responseApiKeyInfo = workflowData!.workspaceId @@ -132,9 +148,11 @@ export const POST = withRouteHandler( return createSuccessResponse({ apiKey: responseApiKeyInfo, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error: unknown) { if (error instanceof WorkflowLockedError) { diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 751171b0b3b..d3c3337e62f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -6,7 +6,6 @@ import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/dep import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { @@ -73,11 +72,11 @@ export const PATCH = withRouteHandler( // Activation requires admin permission, other updates require write const requiredPermission = isActive ? 'admin' : 'write' - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, requiredPermission) + const { error, session } = await validateWorkflowPermissions( + id, + requestId, + requiredPermission + ) if (error) { return createErrorResponse(error.message, error.status) } @@ -98,9 +97,7 @@ export const PATCH = withRouteHandler( workflowId: id, version: versionNum, userId: actorUserId, - workflow: workflowData as Record, requestId, - request, }) if (!activateResult.success) { @@ -145,18 +142,12 @@ export const PATCH = withRouteHandler( } } - const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId - captureServerEvent( - actorUserId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: wsId ?? '', version: versionNum }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - return createSuccessResponse({ success: true, - deployedAt: activateResult.deployedAt, + deployedAt: activateResult.deployedAt ?? null, warnings: activateResult.warnings, + activeDeployment: activateResult.activeDeployment ?? null, + latestDeploymentAttempt: activateResult.latestDeploymentAttempt ?? null, ...(updatedName !== undefined && { name: updatedName }), ...(updatedDescription !== undefined && { description: updatedDescription }), }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index cbc3ea579c8..49f50b3ae21 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -81,6 +81,7 @@ import { import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' @@ -665,6 +666,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, triggerBlockId: parsedTriggerBlockId, startBlockId, @@ -673,6 +675,12 @@ async function handleExecutePost( parentWorkspaceId, } = validation.data const triggerBlockId = parsedTriggerBlockId ?? startBlockId + if (admittedDeploymentVersionId && !isMcpBridgeRequest) { + return NextResponse.json( + { error: 'deploymentVersionId is reserved for internal MCP execution' }, + { status: 400 } + ) + } const headerExecutionId = headerValidation.data[WORKFLOW_EXECUTION_ID_HEADER] let legacyBodyExecutionId: string | undefined if (!headerExecutionId && rawBodyExecutionId !== undefined) { @@ -803,6 +811,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: _deploymentVersionId, triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, @@ -1074,7 +1083,13 @@ async function handleExecutePost( } const workflowData = shouldUseDraftState ? await loadWorkflowFromNormalizedTables(workflowId) - : await loadDeployedWorkflowState(workflowId, workspaceId) + : admittedDeploymentVersionId + ? await loadWorkflowDeploymentVersionState( + workflowId, + admittedDeploymentVersionId, + workspaceId + ) + : await loadDeployedWorkflowState(workflowId, workspaceId) if (req.signal.aborted) { await releaseExecutionSlot(executionId) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 90f093adfa6..2315712fdb5 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -97,6 +97,7 @@ describe('Workflow By ID API Route', () => { folderId: params.folderId ?? params.currentFolderId ?? null, sortOrder: params.sortOrder ?? null, locked: params.locked ?? null, + forkSyncExcluded: params.forkSyncExcluded ?? null, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, @@ -917,6 +918,107 @@ describe('Workflow By ID API Route', () => { // db.select should NOT have been called since no name/folder change expect(mockDbSelect).not.toHaveBeenCalled() }) + + it('should deny forkSyncExcluded update for non-admin users', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + forkSyncExcluded: false, + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'write', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ forkSyncExcluded: true }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Admin access required to exclude workflows from sync') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('should allow admin to toggle forkSyncExcluded and carry it on the response', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + forkSyncExcluded: false, + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'admin', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ forkSyncExcluded: true }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.forkSyncExcluded).toBe(true) + expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-123', + forkSyncExcluded: true, + currentForkSyncExcluded: false, + }) + ) + }) + + it('should skip the mutability check for an exclusion-only update (locked workflow stays togglable)', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + locked: true, + forkSyncExcluded: false, + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'admin', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ forkSyncExcluded: true }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() + }) }) describe('Error handling', () => { diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 08867154aea..1a3a35bc105 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -300,8 +300,22 @@ export const PUT = withRouteHandler( ) } - const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked') - if (hasNonLockUpdate) { + if (updates.forkSyncExcluded !== undefined && authorization.workspacePermission !== 'admin') { + logger.warn( + `[${requestId}] User ${userId} denied permission to change sync exclusion for workflow ${workflowId}` + ) + return NextResponse.json( + { error: 'Admin access required to exclude workflows from sync' }, + { status: 403 } + ) + } + + // Policy flags (lock, sync exclusion) don't modify content, so a locked workflow + // may still have them toggled; everything else requires mutability. + const hasNonPolicyUpdate = Object.keys(updates).some( + (key) => key !== 'locked' && key !== 'forkSyncExcluded' + ) + if (hasNonPolicyUpdate) { await assertWorkflowMutable(workflowId) } if (updates.folderId !== undefined) { @@ -320,6 +334,7 @@ export const PUT = withRouteHandler( currentName: workflowData.name, currentFolderId: workflowData.folderId, currentLocked: workflowData.locked, + currentForkSyncExcluded: workflowData.forkSyncExcluded, ...updates, requestId, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts index 53a70ec7a0d..d69aa933389 100644 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts @@ -35,9 +35,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ })) vi.mock('@/lib/uploads/config', () => ({ - get USE_BLOB_STORAGE() { - return mockUseBlobStorage.value - }, + getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts index 64ba36f0fc5..905ad938d98 100644 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts @@ -9,7 +9,7 @@ import { resolveStorageBillingContext, } from '@/lib/billing/storage' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { getServeStoragePrefix } from '@/lib/uploads/config' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' @@ -92,7 +92,7 @@ export const POST = withRouteHandler( metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, }) - const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace` + const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=workspace` logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`) diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index b18782bd132..caf75870d29 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -7,7 +7,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows' -import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { + listForkExcludedDeployedWorkflows, + loadSourceDeployedStates, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' import { @@ -73,17 +76,24 @@ export const GET = withRouteHandler( .filter((item) => item.mode === 'replace') .map((item) => item.targetWorkflowId) const allTargetIds = plan.items.map((item) => item.targetWorkflowId) - const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] = - await Promise.all([ - loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), - loadTargetDraftSubBlocks(db, replaceTargetIds), - // Source resource labels (per kind) + workflow names, for the cleared-ref list's display. - listForkResourceCandidates(db, auth.sourceWorkspaceId), - db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), - ]) + const [ + storedValues, + targetDraftByWorkflow, + sourceCandidates, + sourceWorkflowRows, + excludedSourceWorkflows, + ] = await Promise.all([ + loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), + loadTargetDraftSubBlocks(db, replaceTargetIds), + // Source resource labels (per kind) + workflow names, for the cleared-ref list's display. + listForkResourceCandidates(db, auth.sourceWorkspaceId), + db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), + // Deployed-but-excluded source workflows, so the preview can show what a sync skips. + listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId), + ]) const storedByKey = new Map( storedValues.map((entry) => [ forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey), @@ -204,6 +214,8 @@ export const GET = withRouteHandler( willCreate: plan.willCreate, willArchive: plan.willArchive, workflows, + excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name), + excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name), unmappedRequired: plan.unmappedRequired.map(toRef), unmappedOptional: plan.unmappedOptional.map(toRef), mcpReauthServerIds: plan.mcpReauthServerIds, diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts new file mode 100644 index 00000000000..f882cb9bf54 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockAssertWorkspaceAdminAccess, mockDbUpdate, mockCaptureServerEvent } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockAssertWorkspaceAdminAccess: vi.fn(), + mockDbUpdate: vi.fn(), + mockCaptureServerEvent: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@sim/db', () => ({ + db: { update: () => mockDbUpdate() }, +})) + +import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route' + +const WORKSPACE_ID = 'workspace-1' +const ADMIN_ID = 'user-1' +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function mockUpdateReturning(rows: Array<{ id: string; name: string }>) { + mockDbUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue(rows), + }), + }), + }) +} + +describe('fork excluded-workflows route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } }) + mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' }) + mockUpdateReturning([]) + }) + + it('returns 401 when there is no session', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await PUT( + createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }), + routeContext + ) + + expect(res.status).toBe(401) + expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + }) + + it('rejects an empty workflowIds batch', async () => { + const res = await PUT( + createMockRequest('PUT', { workflowIds: [], forkSyncExcluded: true }), + routeContext + ) + + expect(res.status).toBe(400) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('requires workspace admin (and the fork entitlement gate) before writing', async () => { + mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }]) + + await PUT( + createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: true }), + routeContext + ) + + expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, ADMIN_ID) + }) + + it('updates the batch, reports the transition count, and records one audit entry', async () => { + mockUpdateReturning([ + { id: 'wf-1', name: 'Alpha' }, + { id: 'wf-2', name: 'Beta' }, + ]) + + const res = await PUT( + createMockRequest('PUT', { workflowIds: ['wf-1', 'wf-2'], forkSyncExcluded: true }), + routeContext + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ updated: 2 }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + actorId: ADMIN_ID, + action: 'workflow.fork_sync_excluded', + resourceId: WORKSPACE_ID, + metadata: expect.objectContaining({ + forkSyncExcluded: true, + workflowCount: 2, + workflowNames: ['Alpha', 'Beta'], + }), + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + ADMIN_ID, + 'fork_excluded_workflows_updated', + expect.objectContaining({ workflow_count: 2, fork_sync_excluded: true }), + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('records the inclusion action when unmarking workflows', async () => { + mockUpdateReturning([{ id: 'wf-1', name: 'Alpha' }]) + + const res = await PUT( + createMockRequest('PUT', { workflowIds: ['wf-1'], forkSyncExcluded: false }), + routeContext + ) + + expect(res.status).toBe(200) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'workflow.fork_sync_included' }) + ) + }) + + it('skips audit and analytics when nothing transitioned', async () => { + mockUpdateReturning([]) + + const res = await PUT( + createMockRequest('PUT', { workflowIds: ['wf-unknown'], forkSyncExcluded: true }), + routeContext + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ updated: 0 }) + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts new file mode 100644 index 00000000000..842713be6d0 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts @@ -0,0 +1,94 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { updateForkExcludedWorkflowsContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' + +const logger = createLogger('ForkExcludedWorkflowsAPI') + +/** Workflow names carried on the audit entry - bounds the row for very large batches. */ +const AUDIT_NAME_LIMIT = 20 + +/** + * Toggle "Exclude from sync" for a batch of the workspace's workflows. An excluded + * workflow never leaves its workspace (promote in either direction, new-fork copies), + * is never overwritten or archived as a sync target, and keeps its identity mapping + * so re-including it resumes replace-mode. Admin-only, matching the sync operations + * the flag governs. Ids outside the workspace, archived workflows, and workflows + * already at the requested value are skipped, so `updated` counts real transitions. + */ +export const PUT = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateForkExcludedWorkflowsContract, req, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { workflowIds, forkSyncExcluded } = parsed.data.body + + const adminWorkspace = await assertWorkspaceAdminAccess(workspaceId, session.user.id) + + const updatedRows = await db + .update(workflow) + .set({ forkSyncExcluded, updatedAt: new Date() }) + .where( + and( + inArray(workflow.id, workflowIds), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + ne(workflow.forkSyncExcluded, forkSyncExcluded) + ) + ) + .returning({ id: workflow.id, name: workflow.name }) + + if (updatedRows.length > 0) { + recordAudit({ + workspaceId, + actorId: session.user.id, + action: forkSyncExcluded + ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED + : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: adminWorkspace.name, + description: `${forkSyncExcluded ? 'Excluded' : 'Included'} ${updatedRows.length} workflow(s) ${forkSyncExcluded ? 'from' : 'in'} fork sync`, + metadata: { + forkSyncExcluded, + workflowCount: updatedRows.length, + workflowNames: updatedRows.slice(0, AUDIT_NAME_LIMIT).map((row) => row.name), + }, + }) + + captureServerEvent( + session.user.id, + 'fork_excluded_workflows_updated', + { + workspace_id: workspaceId, + workflow_count: updatedRows.length, + fork_sync_excluded: forkSyncExcluded, + }, + { groups: { workspace: workspaceId } } + ) + } + + logger.info('Updated fork-sync exclusion', { + workspaceId, + requested: workflowIds.length, + updated: updatedRows.length, + forkSyncExcluded, + }) + + return NextResponse.json({ updated: updatedRows.length }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts index 21363f21f1d..68575860876 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts @@ -63,8 +63,14 @@ export const POST = withRouteHandler( await recordBackgroundWork(db, { workspaceId: id, kind: 'fork_rollback', - status: result.skipped > 0 ? 'completed_with_warnings' : 'completed', - message: `Undid the last sync from "${otherName}"`, + status: + result.skipped > 0 || result.pendingActivations.length > 0 + ? 'completed_with_warnings' + : 'completed', + message: + result.pendingActivations.length > 0 + ? `Undid the last sync from "${otherName}" — ${result.pendingActivations.length} deployment(s) still activating` + : `Undid the last sync from "${otherName}"`, metadata: { actorName: session.user.name ?? undefined, otherWorkspaceId, @@ -73,6 +79,7 @@ export const POST = withRouteHandler( removed: result.archived, unarchived: result.unarchived, skipped: result.skipped, + pendingActivations: result.pendingActivations.length, }, }).catch((error) => logger.error(`[${requestId}] Failed to record rollback activity`, { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 8d35dc7429b..e63aab34a02 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' +import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -9,24 +9,20 @@ import { listWorkspacesQuerySchema } from '@/lib/api/contracts' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import type { PlanCategory } from '@/lib/billing/plan-helpers' +import { getActiveOrganizationId } from '@/lib/auth/session-response' import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { getRandomWorkspaceColor } from '@/lib/workspaces/colors' +import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { - CONTACT_OWNER_TO_UPGRADE_REASON, - evaluateWorkspaceInvitePolicy, - getInvitePlanCategoryForOrganization, - getInvitePlanCategoryForUser, getWorkspaceCreationPolicy, getWorkspaceInvitePolicy, - UPGRADE_TO_INVITE_REASON, + resolveInviteFlags, WORKSPACE_MODE, } from '@/lib/workspaces/policy' -import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('Workspaces') @@ -38,13 +34,6 @@ export const GET = withRouteHandler(async (request: Request) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const activeOrganizationId = - (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null - const creationPolicy = await getWorkspaceCreationPolicy({ - userId: session.user.id, - activeOrganizationId, - }) - const scopeResult = listWorkspacesQuerySchema.safeParse( Object.fromEntries(new URL(request.url).searchParams.entries()) ) @@ -56,20 +45,15 @@ export const GET = withRouteHandler(async (request: Request) => { } const { scope } = scopeResult.data - const settingsQuery = db - .select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId }) - .from(settings) - .where(eq(settings.userId, session.user.id)) - .limit(1) - - const [userWorkspaces, userSettings] = await Promise.all([ - listAccessibleWorkspaceRowsForUser(session.user.id, scope), - settingsQuery, - ]) - - const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null + const activeOrganizationId = getActiveOrganizationId(session) + const payload = await listWorkspacesForViewer({ + userId: session.user.id, + activeOrganizationId, + scope, + }) + const { lastActiveWorkspaceId, creationPolicy } = payload - if (scope === 'active' && userWorkspaces.length === 0) { + if (scope === 'active' && payload.workspaces.length === 0) { if (!creationPolicy.canCreate) { return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy }) } @@ -95,76 +79,10 @@ export const GET = withRouteHandler(async (request: Request) => { } if (scope === 'active') { - await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) + await ensureWorkflowsHaveWorkspace(session.user.id, payload.workspaces[0].id) } - const nonOrgBilledUserIds = [ - ...new Set( - userWorkspaces - .filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION) - .map(({ workspace: ws }) => ws.billedAccountUserId) - ), - ] - const orgIds = [ - ...new Set( - userWorkspaces - .filter( - ({ workspace: ws }) => - ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId - ) - .map(({ workspace: ws }) => ws.organizationId as string) - ), - ] - const planCategoryByBilledUser = new Map() - const planCategoryByOrg = new Map() - await Promise.all([ - ...nonOrgBilledUserIds.map(async (userId) => { - planCategoryByBilledUser.set(userId, await getInvitePlanCategoryForUser(userId)) - }), - ...orgIds.map(async (orgId) => { - planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId)) - }), - ]) - - const workspacesWithPermissions = userWorkspaces.map( - ({ workspace: workspaceDetails, permissionType }) => { - const billedPlanCategory: PlanCategory = - workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION - ? workspaceDetails.organizationId - ? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free') - : 'free' - : (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free') - const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory }) - const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id - - const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser - const inviteDisabledReason = invitePolicy.allowed - ? null - : callerIsBilledUser - ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) - : CONTACT_OWNER_TO_UPGRADE_REASON - - return { - ...workspaceDetails, - role: - workspaceDetails.ownerId === session.user.id - ? 'owner' - : permissionType === 'admin' - ? 'admin' - : 'member', - permissions: permissionType, - inviteMembersEnabled: invitePolicy.allowed, - inviteDisabledReason, - inviteUpgradeRequired: canActOnUpgrade, - } - } - ) - - return NextResponse.json({ - workspaces: workspacesWithPermissions, - lastActiveWorkspaceId, - creationPolicy, - }) + return NextResponse.json(payload) }) // POST /api/workspaces - Create a new workspace @@ -179,8 +97,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const parsed = await parseRequest(createWorkspaceContract, req, {}) if (!parsed.success) return parsed.response const { name, color, skipDefaultWorkflow } = parsed.data.body - const activeOrganizationId = - (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null + const activeOrganizationId = getActiveOrganizationId(session) const creationPolicy = await getWorkspaceCreationPolicy({ userId: session.user.id, activeOrganizationId, @@ -380,13 +297,6 @@ async function createWorkspace({ billedAccountUserId, ownerId: userId, }) - const callerIsBilledUser = billedAccountUserId === userId - const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser - const inviteDisabledReason = invitePolicy.allowed - ? null - : callerIsBilledUser - ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) - : CONTACT_OWNER_TO_UPGRADE_REASON return { id: workspaceId, @@ -401,9 +311,7 @@ async function createWorkspace({ updatedAt: now, role: 'owner', permissions: 'admin', - inviteMembersEnabled: invitePolicy.allowed, - inviteDisabledReason, - inviteUpgradeRequired: canActOnUpgrade, + ...resolveInviteFlags(invitePolicy, billedAccountUserId === userId), } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index fa55168db32..bb9f2618984 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' +import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getProviderIdFromServiceId, OAUTH_PROVIDERS, @@ -30,18 +31,6 @@ import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' const logger = createLogger('ConnectOAuthModal') -/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ -const DISPLAY_NAME_MAX_LENGTH = 255 - -/** - * Reserved tail budget when truncating the username so the auto-numbering - * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. - */ -const COLLISION_SUFFIX_RESERVATION = 5 - -/** Upper bound for the auto-numbering search — pathological if ever reached. */ -const MAX_COLLISION_INDEX = 10000 - const EMPTY_SCOPES: readonly string[] = [] type ServiceIcon = ComponentType<{ className?: string }> @@ -51,44 +40,6 @@ function isHiddenScope(scope: string): boolean { return scope.includes('userinfo.email') || scope.includes('userinfo.profile') } -/** - * Default credential display name. Produces `"{Name}'s {Service}"` when the - * user's name is known, falling back to `"My {Service}"` otherwise. The - * username is truncated so the full string (including any auto-numbering - * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. - * - * When the base name collides with an existing credential in `takenNames`, - * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison - * is case-insensitive to match the duplicate-detection used elsewhere in the - * modal. - */ -function defaultDisplayName( - userName: string | null | undefined, - serviceName: string, - takenNames: ReadonlySet -): string { - const trimmed = userName?.trim() - let base: string - if (trimmed) { - const suffix = `'s ${serviceName}` - const nameBudget = Math.max( - 0, - DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION - ) - const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed - base = `${safeName}${suffix}` - } else { - base = `My ${serviceName}` - } - - if (!takenNames.has(base.toLowerCase())) return base - for (let n = 2; n < MAX_COLLISION_INDEX; n++) { - const candidate = `${base} ${n}` - if (!takenNames.has(candidate.toLowerCase())) return candidate - } - return base -} - /** * Resolves the display name + icon for an OAuth `provider`/`serviceId` pair, * preferring the most specific service entry and falling back to the base @@ -255,7 +206,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } if (!isConnect || prefilled.current || credentialsLoading) return prefilled.current = true - setDisplayName(defaultDisplayName(userName, providerName, takenNames)) + setDisplayName(defaultCredentialDisplayName(userName, providerName, takenNames)) setDescription('') setValidationError(null) setSubmitError(null) diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 91ebc37ea42..b2b4e22d1c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,19 +10,20 @@ import { ChipModalHeader, cn, Duplicate, + Split, ThumbsDown, ThumbsUp, Tooltip, toast, } from '@sim/emcn' -import { GitBranch } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file' +const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' function toPlainText(raw: string): string { return ( @@ -153,6 +154,11 @@ export const MessageActions = memo(function MessageActions({ if (!chatId || !messageId || forkChat.isPending) return try { const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId }) + if (result.failedFileCopies) { + toast.warning( + `${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork` + ) + } useFolderStore.getState().clearChatSelection() router.push(`/workspace/${params.workspaceId}/chat/${result.id}`) } catch { @@ -162,7 +168,10 @@ export const MessageActions = memo(function MessageActions({ const hasContent = Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) - const canFork = false + // A live (just-streamed) assistant message carries a synthetic id that the + // persisted transcript doesn't know — forking it would 400. The button + // appears once the transcript refetch swaps in the persisted message id. + const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId)) if (!hasContent && !canSubmitFeedback && !canFork) return null return ( @@ -220,15 +229,15 @@ export const MessageActions = memo(function MessageActions({ - Fork from here + Branch in new chat )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index fde79283857..a98fe3f98fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -8,7 +8,7 @@ import { Task, Workflow, } from '@sim/emcn/icons' -import { AgentSkillsIcon } from '@/components/icons' +import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle } from '@/blocks/icon-color' @@ -99,4 +99,8 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + mcp: { + label: 'MCP server', + renderIcon: ({ className }) => , + }, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index cf02091848c..62e8bb55d45 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -174,7 +174,7 @@ interface NarrationTextProps { } /** - * A narration (thinking/text) row inside an agent group. The live tail row is + * A narration row inside an agent group. The live tail row is * paced with {@link useSmoothText} so streamed chunks reveal word-by-word * instead of popping in, matching the top-level text treatment. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx new file mode 100644 index 00000000000..2fa6cb4c3ee --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import type { ReactNode, SVGProps } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { getBlockByToolName } from '@/blocks/registry' +import { ToolCallItem } from './tool-call-item' + +vi.mock('@/components/ui', () => ({ + ShimmerText: ({ children }: { children: ReactNode }) => {children}, +})) + +describe('ToolCallItem', () => { + it.each(['executing', 'success', 'error', 'cancelled'] as const)( + 'renders the %s tool row without an icon', + (status) => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Wrote brief.md') + expect(markup).not.toContain('Writing brief.md') + }) + + it('renders the owning integration icon for a resolved integration operation', () => { + vi.mocked(getBlockByToolName).mockReturnValueOnce({ + name: 'Gmail', + icon: (props: SVGProps) => , + } as ReturnType) + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' { + vi.mocked(getBlockByToolName).mockReturnValueOnce({ + name: 'Gmail', + icon: (props: SVGProps) => , + } as ReturnType) + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' { + if (toolName !== CallIntegrationTool.id) return undefined + const toolId = params?.toolId ?? extractStreamingStringArgument(streamingArgs, 'toolId') + return typeof toolId === 'string' ? getBlockByToolName(toolId) : undefined + }, [toolName, params, streamingArgs]) + const liveWorkspaceFileTitle = useMemo(() => { if (toolName !== WorkspaceFile.id || !streamingArgs) return null const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/) @@ -88,7 +103,7 @@ export function ToolCallItem({ ? (getToolCompletedTitle(liveTitle) ?? liveTitle) : liveTitle - const BlockIcon = readBlock?.icon + const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon return (
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 f5ffd95f765..2bd4800c6c7 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 @@ -101,7 +101,8 @@ function endsInlineWord(value: string): boolean { function nextInlineSegmentLabel(segment?: ContentSegment): string { if (!segment) return '' - if (segment.type === 'text' || segment.type === 'thinking') return segment.content + // Thinking segments are never rendered, so they contribute no following text. + if (segment.type === 'text') return segment.content if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || '' return '' } @@ -240,7 +241,7 @@ const MARKDOWN_COMPONENTS = { className={cn( 'text-[var(--text-primary)]', kind - ? 'not-prose inline-flex items-center gap-[5px] no-underline' + ? 'not-prose inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] no-underline transition-colors hover-hover:bg-[var(--surface-6)]' : 'underline decoration-dashed underline-offset-4' )} onClick={(e) => { @@ -260,7 +261,7 @@ const MARKDOWN_COMPONENTS = { {kind && ref && ( )} {children} @@ -343,17 +344,23 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string isStreaming?: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void + /** Reports whether this segment is actively painting text or its own pending-tag indicator. */ + onStreamActivityChange?: (active: boolean) => void } function ChatContentInner({ content, isStreaming = false, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, onRevealStateChange, + onStreamActivityChange, }: ChatContentProps) { const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -363,7 +370,8 @@ function ChatContentInner({ const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content]) const streamedContent = useSmoothText(displayContent, isStreaming) - const isRevealing = isStreaming || streamedContent.length < displayContent.length + const hasRevealBacklog = streamedContent.length < displayContent.length + const isRevealing = isStreaming || hasRevealBacklog useEffect(() => { onRevealStateChangeRef.current?.(isRevealing) @@ -386,9 +394,9 @@ function ChatContentInner({ * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content * without the animate plugin bails at every unoverridden element (`p`, * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in - * place. The settled instance keeps the streaming parser (`parserTree` - * below) so the remount only sheds the spans, never re-interprets the - * markdown. + * place. Every instance renders through the streaming parser (see + * `streamingTree` below) so the remount only sheds the spans, never + * re-interprets the markdown. * * The drain is deliberately one-way: a stream that resumes afterwards * (reconnect/continuation) reveals paced but unfaded, because re-arming @@ -433,19 +441,18 @@ function ChatContentInner({ }, [isRevealing, animationDrained, streamedThisSession]) /** - * `parserTree` (drives `mode`) stays latched for the mount's life: streaming - * mode is the only one that applies remend/incomplete-markdown repair and - * block-split parsing, so a settled message must KEEP the streaming parser — - * swapping to `mode='static'` at drain re-parses the same source through a - * different pipeline (no remend, whole-doc parse) and visibly flashes on any - * reply with unbalanced markdown. `streamingTree` (drives the remount key - * and animation props) additionally drops at drain, so the settled instance - * re-renders through the SAME parser minus the per-word animation spans — - * byte-identical pixels. Only never-streamed mounts (reloaded history) - * render static. + * Every mount renders through the streaming parser (remend + + * incomplete-markdown repair + block-split) — `mode='static'` is never used. + * The two pipelines parse edge-case markdown differently (unbalanced fences, + * list continuation across blocks), so a message you watched stream would + * render subtly differently from the same message reloaded from the DB; one + * pipeline makes in-session and refreshed renders byte-identical. The rows + * are virtualized, so only visible messages pay the block-split mount cost. + * `streamingTree` (the remount key and animation props) still drops at + * drain, so a settled instance re-renders through the SAME parser minus the + * per-word animation spans — identical pixels. */ - const parserTree = isRevealing || streamedThisSession - const streamingTree = parserTree && !animationDrained + const streamingTree = (isRevealing || streamedThisSession) && !animationDrained /** * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a @@ -474,6 +481,12 @@ function ChatContentInner({ () => parseSpecialTags(streamedContent, isRevealing), [streamedContent, isRevealing] ) + const hasPendingIndicator = parsed.hasPendingTag && isRevealing + + useEffect(() => { + onStreamActivityChange?.(hasRevealBacklog || hasPendingIndicator) + return () => onStreamActivityChange?.(false) + }, [hasPendingIndicator, hasRevealBacklog, onStreamActivityChange]) type BlockSegment = Exclude< ContentSegment, @@ -507,7 +520,11 @@ function ChatContentInner({ `[${label}](<#wsres-${s.data.type}-${ref}>)`, nextSegment ) - } else if (s.type === 'text' || s.type === 'thinking') { + } else if (s.type === 'thinking') { + // Model-emitted tag bodies are reasoning, not answer text — + // never rendered (matches the block-level thinking omission in + // message-content and the tag stripping in the inbox executor). + } else if (s.type === 'text') { pendingMarkdown += s.content } else { flushMarkdown() @@ -538,7 +555,6 @@ function ChatContentInner({ > ) })} - {parsed.hasPendingTag && isRevealing && } + {hasPendingIndicator && }
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index f211a5f42e5..8151e32f11c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' +export { QuestionDisplay } from './question' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts new file mode 100644 index 00000000000..5272577cfda --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts @@ -0,0 +1,5 @@ +export { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, + QuestionDisplay, +} from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts new file mode 100644 index 00000000000..e3761f3d0e7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, + QuestionDisplay, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const QUESTIONS: QuestionItem[] = [ + { + type: 'single_select', + prompt: 'How should I handle the duplicates?', + options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], + }, + { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], + }, + { + type: 'multi_select', + prompt: 'What time zone should the daily report run in?', + options: [ + { id: 'est', label: 'EST' }, + { id: 'pst', label: 'PST' }, + ], + }, +] + +describe('formatQuestionAnswerMessage', () => { + it('sends a prompt-answer line for a single question', () => { + expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry' + ) + }) + + it('sends one prompt-answer line per question for multi-step batches', () => { + expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'Delete 4 archived workflows? — Cancel\n' + + 'What time zone should the daily report run in? — EST' + ) + }) +}) + +describe('parseQuestionAnswerMessage', () => { + it('round-trips what formatQuestionAnswerMessage produces', () => { + const answers = ['Keep the newest entry', 'Cancel', 'EST, PST'] + const message = formatQuestionAnswerMessage(QUESTIONS, answers) + expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) + }) + + it('round-trips a single question', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them']) + }) + + it('rejects an unrelated user message (dismissed card, typed something else)', () => { + expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull() + }) + + it('rejects when the line count does not match the question count', () => { + const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B']) + expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull() + }) + + it('rejects when a line pairs with the wrong prompt', () => { + const swapped = + 'Delete 4 archived workflows? — Cancel\n' + + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'What time zone should the daily report run in? — EST' + expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull() + }) + + it('preserves em-dashes inside the answer text', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([ + 'newest — but keep backups', + ]) + }) +}) + +describe('QuestionDisplay', () => { + it('renders multi-select recap answers as separate, spaced rows', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[2]], + answers: ['EST, PST'], + }) + ) + }) + + const answerContainer = container.querySelector('.gap-1') + expect(Array.from(answerContainer?.children ?? []).map((child) => child.textContent)).toEqual([ + 'EST', + 'PST', + ]) + + act(() => root.unmount()) + container.remove() + }) + + it('renders Something else as a placeholder instead of an option', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[0]], + onSelect: () => undefined, + }) + ) + }) + + const input = container.querySelector('input') + expect(input).not.toBeNull() + expect(input?.placeholder).toBe('Something else') + expect(input?.className).toContain('placeholder:text-[var(--text-muted)]') + expect(container.textContent).not.toContain('Something else') + + const optionButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Keep the newest entry' + ) + expect(optionButton).toBeDefined() + + act(() => root.unmount()) + container.remove() + }) + + it('focuses the Something else input when its multi-select checkbox is selected', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[2]], + onSelect: () => undefined, + }) + ) + }) + + const input = container.querySelector('input') + const checkbox = container.querySelector( + 'button[aria-label="Include \\"Something else\\" in the answer"]' + ) + expect(input).not.toBeNull() + expect(checkbox).not.toBeNull() + + act(() => checkbox?.click()) + + expect(checkbox?.dataset.state).toBe('checked') + expect(document.activeElement).toBe(input) + + act(() => checkbox?.focus()) + act(() => checkbox?.click()) + + expect(checkbox?.dataset.state).toBe('unchecked') + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx new file mode 100644 index 00000000000..9475f0fd4da --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -0,0 +1,471 @@ +'use client' + +import { useRef, useState } from 'react' +import { + ArrowRight, + Button, + Check, + ChevronLeft, + ChevronRight, + checkboxIconVariants, + checkboxVariants, + cn, + X, +} from '@sim/emcn' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +/** + * Builds the single user message sent after the final question is answered: + * one `Prompt — Answer` line per question, for lone questions too. The uniform + * shape is what lets the chat pair this message back to its question card + * (see parseQuestionAnswerMessage) and render the card as the user turn + * instead of echoing a duplicate bubble. + */ +export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { + return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') +} + +/** + * Strictly matches a user message against a question batch's answer format: + * exactly one `Prompt — Answer` line per question, in order. Returns the + * answers, or null when the message is not this batch's answer — a dismissed + * card followed by an unrelated typed message must not match. + */ +export function parseQuestionAnswerMessage( + questions: QuestionItem[], + content: string +): string[] | null { + const lines = content.split('\n') + if (lines.length !== questions.length) return null + const answers: string[] = [] + for (const [i, question] of questions.entries()) { + const prefix = `${question.prompt} — ` + if (!lines[i].startsWith(prefix)) return null + answers.push(lines[i].slice(prefix.length)) + } + return answers +} + +const OPTION_ROW_CLASSES = + 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' + +/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ +const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' + +/** + * Leading checkbox slot for multi_select rows. Purely presentational — it + * reuses the emcn Checkbox chrome via its exported variants, but the row + * button (or the free-text input) owns the interaction, so nesting a real + * Radix checkbox button inside the row button is avoided. + */ +function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) { + return ( +
+ + {checked && ( + + )} + +
+ ) +} + +type QuestionPhase = 'active' | 'answered' | 'dismissed' + +interface QuestionDisplayProps { + data: QuestionItem[] + /** + * Answers resolved from the transcript (the paired user message that + * answered this card). When present the card renders as the answered recap + * — it IS the user turn; the paired message bubble is hidden by the chat. + */ + answers?: string[] + /** Sends the combined answer as a user message; undefined renders the div inert. */ + onSelect?: (message: string) => void +} + +/** + * Inline renderer for the `` special tag: a chat-inline div with the + * user input's chrome, the current question's prompt at the top left, dismiss + * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and + * suggested-action option rows beneath, always followed by a custom-answer + * text field whose placeholder reads "Something else". `single_select` + * answers and advances on click (or on submitting typed text); `multi_select` + * rows toggle checkboxes and an option-styled Submit row confirms the step. + * Answering the last question sends one combined user message and collapses + * the div to a question/answer recap. + */ +export function QuestionDisplay({ + data, + answers: transcriptAnswers, + onSelect, +}: QuestionDisplayProps) { + const freeTextInputRef = useRef(null) + const freeTextCheckboxRef = useRef(null) + const disabled = !onSelect + const [phase, setPhase] = useState('active') + const [step, setStep] = useState(0) + const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) + const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) + const [freeText, setFreeText] = useState('') + // multi_select only: whether the typed "Something else" text is included in + // the answer. Unchecking keeps the text; it just stops counting. + const [customCheckedByStep, setCustomCheckedByStep] = useState(() => + data.map(() => false) + ) + + // The typed text that actually joins a step's answer: multi_select customs + // only count while checked; single_select customs always count. + const customFor = (i: number, customs: string[]): string => + data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') + + const containerClasses = + 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' + + // Transcript answers win over local state: they survive reloads (local + // phase does not) and keep live + rehydrated renders identical. + const localAnswers = + phase === 'answered' + ? data.map((question, i) => + answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep)) + ) + : null + const recapAnswers = transcriptAnswers ?? localAnswers + if (data.length > 0 && recapAnswers) { + return ( +
+ {data.map((question, i) => ( +
+

{question.prompt}

+
+ {answerPartsForDisplay(question, recapAnswers[i] ?? '').map((answer, answerIndex) => ( +

{answer}

+ ))} +
+
+ ))} +
+ ) + } + + if (data.length === 0 || phase === 'dismissed') return null + + const question = data[step] + const isLast = step === data.length - 1 + const options = question.options + const selected = selectedByStep[step] ?? [] + const isMulti = question.type === 'multi_select' + + const commitCustom = (): string[] => { + const next = [...customByStep] + next[step] = freeText.trim() + setCustomByStep(next) + return next + } + + const goToStep = (next: number) => { + commitCustom() + setStep(next) + const prefill = customByStep[next] ?? '' + setFreeText(prefill) + } + + const finishStep = (selections: string[][], customs: string[]) => { + if (!isLast) { + setStep(step + 1) + const prefill = customs[step + 1] ?? '' + setFreeText(prefill) + return + } + setPhase('answered') + onSelect?.( + formatQuestionAnswerMessage( + data, + data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs))) + ) + ) + } + + const handleSingleSelect = (label: string) => { + const selections = [...selectedByStep] + selections[step] = [label] + setSelectedByStep(selections) + const customs = [...customByStep] + customs[step] = '' + setCustomByStep(customs) + setFreeText('') + finishStep(selections, customs) + } + + const handleMultiToggle = (label: string) => { + const selections = [...selectedByStep] + const current = selections[step] ?? [] + selections[step] = current.includes(label) + ? current.filter((l) => l !== label) + : [...current, label] + setSelectedByStep(selections) + } + + /** multi_select confirm: commits selections and/or typed text, then advances. */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) + } + + /** Sets whether the typed "Something else" text counts — never touches the text. */ + const setCustomChecked = (checked: boolean) => { + const next = [...customCheckedByStep] + next[step] = checked + setCustomCheckedByStep(next) + } + + const toggleCustomChecked = () => { + const isChecked = customCheckedByStep[step] ?? false + setCustomChecked(!isChecked) + if (!isChecked) freeTextInputRef.current?.focus() + } + + /** single_select free-text arrow: the typed text IS the answer. */ + const submitSingleFreeText = () => { + const customs = commitCustom() + const selections = [...selectedByStep] + selections[step] = [] + setSelectedByStep(selections) + finishStep(selections, customs) + } + + const stepAnswered = (i: number): boolean => { + if ((selectedByStep[i]?.length ?? 0) > 0) return true + const text = i === step ? freeText : (customByStep[i] ?? '') + if (text.trim().length === 0) return false + return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true + } + + const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) + + return ( +
+
+

+ {question.prompt} +

+
+ {data.length > 1 && ( +
+ + + {step + 1} of {data.length} + + +
+ )} + {!disabled && ( + + )} +
+
+
+ {options.map((option, i) => { + const isSelected = selected.includes(option.label) + return ( + + ) + })} +
0 && 'border-t')}> + {isMulti && ( +
+ +
+ )} + { + if (isMulti) setCustomChecked(true) + }} + onChange={(e) => setFreeText(e.target.value)} + onBlur={(event) => { + if ( + isMulti && + event.relatedTarget !== freeTextCheckboxRef.current && + freeText.trim().length === 0 + ) { + setCustomChecked(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Enter' && canSubmitStep) { + e.preventDefault() + if (isMulti) { + submitMultiStep() + } else { + submitSingleFreeText() + } + } + }} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' + /> + {!isMulti && ( + + )} +
+ {isMulti && ( + + )} +
+
+ ) +} + +/** + * A step's combined answer: selected option labels in option order, with the + * typed "Something else" entry appended last. single_select carries at most + * one selection, so this collapses to the chosen label or the typed text. + */ +function answerFor(question: QuestionItem, selected: string[], custom: string): string { + const ordered = question.options + .map((option) => option.label) + .filter((label) => selected.includes(label)) + const parts = custom.trim() ? [...ordered, custom.trim()] : ordered + return parts.join(', ') +} + +/** Separates known multi-select labels for the recap without changing the wire answer. */ +function answerPartsForDisplay(question: QuestionItem, answer: string): string[] { + if (question.type !== 'multi_select') return [answer] + + const parts: string[] = [] + let remaining = answer + + for (const option of question.options) { + if (remaining === option.label) { + parts.push(option.label) + remaining = '' + break + } + + const optionPrefix = `${option.label}, ` + if (remaining.startsWith(optionPrefix)) { + parts.push(option.label) + remaining = remaining.slice(optionPrefix.length) + } + } + + if (remaining) parts.push(remaining) + return parts.length > 0 ? parts : [answer] +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index c45b9199c31..16dbf2783e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -6,6 +6,10 @@ export type { MothershipErrorTagData, OptionsTagData, ParsedSpecialContent, + QuestionItem, + QuestionOption, + QuestionTagData, + QuestionType, RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, @@ -17,9 +21,12 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, + parseLastQuestionTag, + parseQuestionTagBody, parseSpecialTags, parseTagAttributes, parseTextTagBody, + QUESTION_TYPES, SpecialTags, USAGE_UPGRADE_ACTIONS, WORKSPACE_RESOURCE_TAG_TYPES, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts new file mode 100644 index 00000000000..638e3c3a747 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + parseQuestionTagBody, + parseSpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const SINGLE_SELECT = { + type: 'single_select', + prompt: 'How should I handle the duplicate emails?', + options: [ + { id: 'keep_newest', label: 'Keep the newest entry' }, + { id: 'merge', label: 'Merge fields into one row' }, + ], +} + +const YES_NO = { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], +} + +const MULTI_SELECT = { + type: 'multi_select', + prompt: 'Which channels should the report go to?', + options: [ + { id: 'slack', label: 'Slack' }, + { id: 'email', label: 'Email' }, + { id: 'sheet', label: 'Google Sheet' }, + ], +} + +describe('parseQuestionTagBody', () => { + it('normalizes a single object body to a one-element array', () => { + expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT]) + }) + + it('preserves array order for multi-step bodies', () => { + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) + }) + + it('accepts multi_select questions', () => { + expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) + }) + + it('rejects single_select without options', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe( + null + ) + }) + + it('rejects empty options', () => { + expect( + parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] })) + ).toBe(null) + }) + + it('rejects the removed text and confirm types', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( + null + ) + expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) + }) + + it('strips agent-supplied catch-all options (the card provides its own)', () => { + const withOther = { + ...SINGLE_SELECT, + options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }], + } + expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT]) + }) + + it('rejects a question whose every option is a catch-all', () => { + const onlyOther = { + type: 'single_select', + prompt: 'Pick one', + options: [ + { id: 'a', label: 'Other' }, + { id: 'b', label: 'None of the above' }, + ], + } + expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null) + }) + + it('rejects an empty prompt', () => { + expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null) + }) + + it('rejects a malformed option', () => { + expect( + parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] })) + ).toBe(null) + }) + + it('rejects an array containing one invalid question', () => { + expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe( + null + ) + }) + + it('rejects empty arrays and non-JSON bodies', () => { + expect(parseQuestionTagBody('[]')).toBe(null) + expect(parseQuestionTagBody('not json')).toBe(null) + }) +}) + +describe('parseSpecialTags with ', () => { + it('extracts a complete question tag interleaved with text', () => { + const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.` + const { segments, hasPendingTag } = parseSpecialTags(content, false) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before the tag. ' }, + { type: 'question', data: [SINGLE_SELECT] }, + { type: 'text', content: ' After the tag.' }, + ]) + }) + + it('extracts a multi-step array body as one segment', () => { + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` + const { segments } = parseSpecialTags(content, false) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) + }) + + it('flags an unclosed question tag as pending while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags( + 'Thinking about it. [{"type":"single_sel', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { + const { segments, hasPendingTag } = parseSpecialTags( + 'Before. {"type":"single_select"} After.', + false + ) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before. ' }, + { type: 'text', content: ' After.' }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 5fa56531a0b..a23bc0427d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -5,8 +5,9 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseUserPermissionsContext } = vi.hoisted(() => ({ +const { mockUseUserPermissionsContext, mockUseWorkspaceCredential } = vi.hoisted(() => ({ mockUseUserPermissionsContext: vi.fn(), + mockUseWorkspaceCredential: vi.fn(), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ @@ -17,8 +18,15 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) +vi.mock('@/hooks/queries/credentials', () => ({ + useWorkspaceCredential: mockUseWorkspaceCredential, +})) + import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' -import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import { + parseSpecialTags, + SpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' /** * Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the @@ -38,6 +46,7 @@ describe('CredentialDisplay link tag', () => { beforeEach(() => { vi.clearAllMocks() mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) + mockUseWorkspaceCredential.mockReturnValue({ data: null }) }) it('does not render an anchor for a javascript: scheme value', () => { @@ -88,4 +97,64 @@ describe('CredentialDisplay link tag', () => { expect(container.querySelector('a')).toBeNull() act(() => root.unmount()) }) + + it('does not query a credential for a plain connect URL', () => { + const { root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: 'https://github.com/login/oauth/authorize?client_id=abc', + }) + + expect(mockUseWorkspaceCredential).toHaveBeenCalledWith(undefined) + act(() => root.unmount()) + }) + + it('labels a reconnect URL with the credential display name', () => { + mockUseWorkspaceCredential.mockReturnValue({ + data: { id: 'cred-1', displayName: "Justin's Gmail" }, + }) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', + }) + + expect(mockUseWorkspaceCredential).toHaveBeenCalledWith('cred-1') + expect(container.textContent).toContain("Reconnect Justin's Gmail") + act(() => root.unmount()) + }) + + it('falls back to the provider label while the reconnect credential is unresolved', () => { + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', + }) + + expect(container.textContent).toContain('Reconnect google-email') + act(() => root.unmount()) + }) +}) + +describe('parseSpecialTags sim_key placeholder', () => { + it('accepts a value-less {"type":"sim_key"} tag as a credential segment', () => { + const { segments } = parseSpecialTags('{"type":"sim_key"}', false) + const credential = segments.find((s) => s.type === 'credential') + expect(credential).toEqual({ type: 'credential', data: { type: 'sim_key' } }) + }) + + it('still accepts the legacy {"redacted":true} form as a value-less sim_key placeholder', () => { + const { segments } = parseSpecialTags( + '{"type":"sim_key","redacted":true}', + false + ) + const credential = segments.find((s) => s.type === 'credential') + expect(credential?.type).toBe('credential') + if (credential?.type === 'credential') { + expect(credential.data.type).toBe('sim_key') + expect(credential.data.value).toBeUndefined() + } + }) }) 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 50eb261d7a9..31df87e34ae 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 @@ -21,12 +21,14 @@ import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { usePersonalEnvironment, useSavePersonalEnvironment, @@ -78,7 +80,6 @@ export interface CredentialTagData { value?: string type: CredentialTagType provider?: string - redacted?: boolean /** * Env-var key name to save the pasted secret under (secret_input only), * e.g. "OPENAI_API_KEY". @@ -100,6 +101,30 @@ export interface FileTagData { content: string } +export const QUESTION_TYPES = ['single_select', 'multi_select'] as const + +export type QuestionType = (typeof QUESTION_TYPES)[number] + +export interface QuestionOption { + id: string + label: string +} + +/** + * One question in a `` tag: a single_select or multi_select with at + * least one real option. The card always appends its own free-text "Something + * else" row, so agent-supplied catch-all options ("Other", "Something else", + * ...) are stripped during parsing. + */ +export interface QuestionItem { + type: QuestionType + prompt: string + options: QuestionOption[] +} + +/** Normalized `` payload: single-object bodies become a one-element array. */ +export type QuestionTagData = QuestionItem[] + export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] @@ -119,6 +144,7 @@ export type ContentSegment = | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -127,6 +153,7 @@ export type RuntimeSpecialTagName = | 'mothership-error' | 'file' | 'workspace_resource' + | 'question' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -140,6 +167,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'mothership-error', 'file', 'workspace_resource', + 'question', ] as const const SPECIAL_TAG_NAMES = [ @@ -149,6 +177,7 @@ const SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'workspace_resource', + 'question', ] as const function isRecord(value: unknown): value is Record { @@ -195,7 +224,11 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } - if (value.redacted === true) return value.value === undefined || typeof value.value === 'string' + // A sim_key chip is platform-filled: the model only marks where the workspace + // API key belongs (it never holds the value) and Sim injects it from the tool + // result, so the tag is valid with or without a `value`. Every other rendered + // type (e.g. link) needs a string value to render. + if (value.type === 'sim_key') return true return typeof value.value === 'string' } @@ -226,6 +259,83 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT return id.length > 0 } +function isQuestionOption(value: unknown): value is QuestionOption { + if (!isRecord(value)) return false + return typeof value.id === 'string' && typeof value.label === 'string' +} + +/** + * Catch-all labels the agent must not supply as options — the card renders + * its own free-text "Something else" row. Matching options are stripped; a + * question left with no real options is invalid. + */ +const SELF_PROVIDED_OPTION_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) + +function isQuestionItem(value: unknown): value is QuestionItem { + if (!isRecord(value)) return false + if ( + typeof value.type !== 'string' || + !(QUESTION_TYPES as readonly string[]).includes(value.type) + ) { + return false + } + if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false + return ( + Array.isArray(value.options) && + value.options.length > 0 && + value.options.every(isQuestionOption) + ) +} + +/** Strips agent-supplied catch-all options; null when none remain. */ +function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null { + const options = item.options.filter( + (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase()) + ) + if (options.length === 0) return null + return options.length === item.options.length ? item : { ...item, options } +} + +/** + * Parses a `` tag body. Accepts a single question object or a + * non-empty array of them; single objects are normalized to a one-element + * array so the renderer only handles the array shape. + */ +/** + * Extracts the last complete `` tag payload from raw message + * content. Used by the chat list to pair an assistant question card with the + * user message that answered it. + */ +export function parseLastQuestionTag(content: string): QuestionTagData | null { + const matches = content.match(/([\s\S]*?)<\/question>/g) + if (!matches || matches.length === 0) return null + const last = matches[matches.length - 1] + return parseQuestionTagBody(last.slice(''.length, -''.length)) +} + +export function parseQuestionTagBody(body: string): QuestionTagData | null { + try { + const parsed = JSON.parse(body) as unknown + const items = Array.isArray(parsed) ? parsed : [parsed] + if (items.length === 0 || !items.every(isQuestionItem)) return null + const sanitized: QuestionItem[] = [] + for (const item of items) { + const clean = sanitizeQuestionItem(item) + if (!clean) return null + sanitized.push(clean) + } + return sanitized + } catch { + return null + } +} + export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -274,6 +384,7 @@ function parseSpecialTagData( | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -305,6 +416,11 @@ function parseSpecialTagData( return data ? { type: 'workspace_resource', data } : null } + if (tagName === 'question') { + const data = parseQuestionTagBody(body) + return data ? { type: 'question', data } : null + } + return null } @@ -397,6 +513,8 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS interface SpecialTagsProps { segment: Exclude + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void } @@ -407,6 +525,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, }: SpecialTagsProps) { @@ -423,6 +542,10 @@ export function SpecialTags({ return case 'workspace_resource': return + case 'question': + return ( + + ) default: return null } @@ -734,36 +857,58 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } -function CredentialDisplay({ data }: { data: CredentialTagData }) { +function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() + // A connect URL carrying a credentialId re-authorizes that existing + // credential in place (reconnect) rather than creating a new one. + const reconnectCredentialId = useMemo(() => { + if (!data.value) return undefined + try { + return new URL(data.value).searchParams.get('credentialId') ?? undefined + } catch { + return undefined + } + }, [data.value]) + const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) + + // Connecting a credential mutates the workspace — hide it from read-only members. + if (!data.provider || !canEdit) return null + // The connect link value comes from the streamed model output, so only + // render it as a clickable link when it resolves to a real http(s) URL. + if (!data.value || !isSafeHttpUrl(data.value)) return null + const Icon = getCredentialIcon(data.provider) ?? LockIcon + const label = reconnectCredentialId + ? `Reconnect ${reconnectCredential?.displayName ?? data.provider}` + : `Connect ${data.provider}` + return ( + + {createElement(Icon, { className: 'size-[16px] shrink-0' })} + {label} + + + ) +} + +function CredentialDisplay({ data }: { data: CredentialTagData }) { if (data.type === 'secret_input') { return } if (data.type === 'link') { - // Connecting a credential mutates the workspace — hide it from read-only members. - if (!data.provider || !canEdit) return null - // The connect link value comes from the streamed model output, so only - // render it as a clickable link when it resolves to a real http(s) URL. - if (!data.value || !isSafeHttpUrl(data.value)) return null - const Icon = getCredentialIcon(data.provider) ?? LockIcon - return ( - - {createElement(Icon, { className: 'size-[16px] shrink-0' })} - Connect {data.provider} - - - ) + return } if (data.type === 'sim_key') { - return + // SecretReveal masks itself when there's no value, so a value-less tag (the + // model's placeholder / persisted form) renders masked and a Sim-filled tag + // reveals the key + copy button — no separate "redacted" flag needed. + return } return null @@ -787,7 +932,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { : 'Only the workspace owner can manage this workspace’s usage limits.' return ( -
+
{ + it('refines a completed credential rename with its previous and new names', () => { + const segments = parseBlocks([ + { + type: 'tool_call', + toolCall: { + id: 'rename-credential', + name: 'manage_credential', + status: 'success', + params: { operation: 'rename', displayName: 'Production Stripe' }, + result: { + success: true, + output: { + previousDisplayName: 'Stripe', + displayName: 'Production Stripe', + }, + }, + }, + timestamp: 1, + }, + ]) + + expect(segments).toHaveLength(1) + const group = segments[0] + if (group.type !== 'agent_group') throw new Error('expected mothership group') + const tool = group.items[0] + if (tool?.type !== 'tool') throw new Error('expected credential tool') + expect(tool.data.displayTitle).toBe('Renamed Stripe to Production Stripe') + }) + it('nests a deploy subagent inside the workflow subagent that spawned it', () => { const blocks: ContentBlock[] = [ subagentStart('workflow', 'S1', 'main'), @@ -231,6 +265,65 @@ describe('parseBlocks span-identity tree', () => { expect(nested.group.agentName).toBe('file') }) + it('suppresses subagent thinking while keeping the delegating spinner', () => { + const blocks: ContentBlock[] = [ + subagentStart('workflow', 'S1', 'main'), + { + type: 'subagent_thinking', + content: 'reasoning about the fix', + spanId: 'S1', + subagent: 'workflow', + timestamp: 2, + }, + ] + + const segments = parseBlocks(blocks) + expect(segments).toHaveLength(1) + if (segments[0].type !== 'agent_group') throw new Error('expected workflow group') + expect(segments[0].items).toEqual([]) + // Suppressed reasoning does not count as visible output or clear activity. + expect(segments[0].isDelegating).toBe(true) + }) + + it('does not create visible output when thinking arrives before its subagent start', () => { + const blocks: ContentBlock[] = [ + { + type: 'subagent_thinking', + content: 'early reasoning', + spanId: 'S1', + parentSpanId: 'main', + subagent: 'workflow', + timestamp: 1, + }, + subagentStart('workflow', 'S1', 'main'), + ] + + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected workflow group') + expect(group.agentName).toBe('workflow') + expect(group.items).toEqual([]) + }) + + it('renders only assistant text after suppressed subagent thinking', () => { + const blocks: ContentBlock[] = [ + subagentStart('workflow', 'S1', 'main'), + { + type: 'subagent_thinking', + content: 'planning', + spanId: 'S1', + subagent: 'workflow', + timestamp: 2, + }, + { type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'workflow', timestamp: 3 }, + ] + + const segments = parseBlocks(blocks) + if (segments[0].type !== 'agent_group') throw new Error('expected workflow group') + expect(segments[0].items).toEqual([{ type: 'text', content: 'done' }]) + expect(segments[0].isDelegating).toBe(false) + }) + it('falls back to legacy flat grouping when blocks have no span identity', () => { const blocks: ContentBlock[] = [ { type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 }, @@ -302,32 +395,6 @@ describe('completed tool titles', () => { }) describe('narration text seams', () => { - it('inserts a space between glued consecutive blocks', () => { - const blocks: ContentBlock[] = [ - subagentStart('research', 'S1', 'main'), - { - type: 'subagent_thinking', - content: 'that triggered it.', - spanId: 'S1', - subagent: 'research', - timestamp: 2, - }, - { - type: 'subagent_text', - content: 'The failing block is X.', - spanId: 'S1', - subagent: 'research', - timestamp: 3, - }, - ] - const segments = parseBlocks(blocks) - const group = segments.find((s) => s.type === 'agent_group') - if (!group || group.type !== 'agent_group') throw new Error('expected group') - const text = group.items.find((i) => i.type === 'text') - if (!text || text.type !== 'text') throw new Error('expected text') - expect(text.content).toBe('that triggered it. The failing block is X.') - }) - it('never inserts a space into a segment split mid-word or mid-URL', () => { const seam = (first: string, second: string): string => { const blocks: ContentBlock[] = [ @@ -386,3 +453,169 @@ describe('narration text seams', () => { expect(text.content).toBe('first sentence. second sentence.') }) }) + +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[] = [ + { type: 'thinking', content: 'planning the search', timestamp: 1 }, + mainToolCall('t1', 'grep'), + { type: 'thinking', content: 'now read the workflow', timestamp: 1 }, + mainToolCall('t2', 'read'), + mainToolCall('t3', 'read'), + ] + const segments = parseBlocks(blocks) + const groups = segments.filter((s) => s.type === 'agent_group') + expect(groups).toHaveLength(1) + if (groups[0].type !== 'agent_group') throw new Error('expected group') + expect(groups[0].agentName).toBe('mothership') + expect(groups[0].items).toHaveLength(3) + }) + + it('still splits the mothership run on real main text', () => { + const blocks: ContentBlock[] = [ + mainToolCall('t1', 'grep'), + mainText('Here is what I found so far.'), + mainToolCall('t2', 'read'), + ] + const segments = parseBlocks(blocks) + const groups = segments.filter((s) => s.type === 'agent_group') + expect(groups).toHaveLength(2) + }) + + it('does not let main thinking affect subagent lane grouping', () => { + const blocks: ContentBlock[] = [ + { type: 'subagent', content: 'workflow', parentToolCallId: 'd1', timestamp: 1 }, + { type: 'subagent_text', content: 'working', parentToolCallId: 'd1', timestamp: 1 }, + { type: 'thinking', content: 'main reasoning', timestamp: 1 }, + { type: 'subagent_text', content: 'later chunk with no lane tag', timestamp: 1 }, + ] + const segments = parseBlocks(blocks) + const groups = segments.filter((s) => s.type === 'agent_group') + expect(groups).toHaveLength(1) + if (groups[0].type !== 'agent_group') throw new Error('expected group') + // Thinking is absent from persistence, so it cannot split the live lane. + expect(groups[0].items).toHaveLength(1) + expect(groups[0].items[0]).toEqual({ + type: 'text', + content: 'workinglater chunk with no lane tag', + }) + }) + + it('suppresses subagent thinking inside the legacy lane', () => { + const blocks: ContentBlock[] = [ + { type: 'subagent', content: 'workflow', parentToolCallId: 'd1', timestamp: 1 }, + { + type: 'subagent_thinking', + content: 'legacy reasoning', + parentToolCallId: 'd1', + timestamp: 2, + }, + { type: 'subagent_text', content: 'output', parentToolCallId: 'd1', timestamp: 3 }, + ] + const segments = parseBlocks(blocks) + const groups = segments.filter((s) => s.type === 'agent_group') + expect(groups).toHaveLength(1) + if (groups[0].type !== 'agent_group') throw new Error('expected group') + expect(groups[0].items).toEqual([{ type: 'text', content: 'output' }]) + }) +}) + +describe('assistantMessageHasVisibleExecutingTool', () => { + it('does not treat an open subagent lane as an executing tool row', () => { + expect(assistantMessageHasVisibleExecutingTool([subagentStart('workflow', 'S1', 'main')])).toBe( + false + ) + }) + + it('keeps a visible executing tool as active work', () => { + const blocks: ContentBlock[] = [ + subagentStart('workflow', 'S1', 'main'), + { + type: 'tool_call', + toolCall: { id: 't1', name: 'grep', status: 'executing', calledBy: 'workflow' }, + spanId: 'S1', + timestamp: 3, + }, + ] + expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(true) + }) + + it('does not let open parallel lanes suppress the single turn-level indicator', () => { + const blocks: ContentBlock[] = [ + subagentStart('workflow', 'S1', 'main'), + subagentStart('search', 'S2', 'main'), + ] + expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) + }) + + it('ignores the executing dispatch tool represented by its subagent lane', () => { + const blocks: ContentBlock[] = [ + { + type: 'tool_call', + toolCall: { id: 'dispatch-1', name: 'workspace_file', status: 'executing' }, + timestamp: 1, + }, + { + ...subagentStart('file', 'S1', 'main'), + parentToolCallId: 'dispatch-1', + }, + ] + expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) + }) +}) 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 dadd78aab6e..3d3d0be2458 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,6 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 +18,7 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' +const STREAM_IDLE_DELAY_MS = 1_500 interface TextSegment { type: 'text' @@ -47,6 +48,56 @@ interface StoppedSegment { type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment +function getAgentGroupActivityKey(items: AgentGroupItem[]): string { + return items + .map((item) => { + if (item.type === 'text') { + return `text:${item.content.length}` + } + if (item.type === 'tool') { + return [ + 'tool', + item.data.id, + item.data.status, + item.data.displayTitle, + item.data.streamingArgs?.length ?? 0, + ].join(':') + } + return [ + 'agent', + item.group.id, + item.group.isDelegating ? 1 : 0, + item.group.isOpen ? 1 : 0, + getAgentGroupActivityKey(item.group.items), + ].join(':') + }) + .join('|') +} + +/** + * Compact identity for what the transcript is visibly rendering. Main-lane + * reasoning and other suppressed blocks intentionally do not affect it, while + * activity in every nested/parallel lane does. + */ +function getVisibleStreamActivityKey(segments: MessageSegment[]): string { + return segments + .map((segment) => { + if (segment.type === 'text') return `text:${segment.id}:${segment.content.length}` + if (segment.type === 'options') { + return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}` + } + if (segment.type === 'stopped') return 'stopped' + return [ + 'agent', + segment.id, + segment.isDelegating ? 1 : 0, + segment.isOpen ? 1 : 0, + getAgentGroupActivityKey(segment.items), + ].join(':') + }) + .join('||') +} + const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS)) /** @@ -100,6 +151,17 @@ function getOverrideDisplayTitle(tc: NonNullable): str if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) { return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text } + if (tc.name === 'manage_credential' && tc.params?.operation === 'rename') { + const output = tc.result?.output + const result = output && typeof output === 'object' ? (output as Record) : null + const previousDisplayName = result?.previousDisplayName + if (typeof previousDisplayName === 'string' && previousDisplayName.trim()) { + return getToolDisplayTitle(tc.name, { + ...tc.params, + previousDisplayName: previousDisplayName.trim(), + }) + } + } return undefined } @@ -137,34 +199,18 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { } } -type NarrationChannel = 'thinking' | 'assistant' - /** * Appends narration content to a group, merging into the previous text item. - * When a thinking run and a text run meet, their contents can glue together - * without any whitespace at the seam. The merge repairs only that semantic - * channel transition, and only at an unambiguous sentence boundary — trailing - * punctuation meeting a fresh alphanumeric start. Same-channel continuations - * (streamed chunks of one run, resume legs) are concatenated verbatim, so a - * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the - * caller's per-parse tracker of each group's most recent narration channel. + * Streamed chunks and resume legs are concatenated verbatim, so a token split + * like `v2.` + `1` is never mutated. */ -function appendTextItem( - group: AgentGroupSegment, - content: string, - channel: NarrationChannel, - lastChannelByGroup: Map -): void { +function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { - const isChannelSeam = lastChannelByGroup.get(group) !== channel - const needsSpace = - isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content) - lastItem.content += (needsSpace ? ' ' : '') + content + lastItem.content += content } else { group.items.push({ type: 'text', content }) } - lastChannelByGroup.set(group, channel) } /** @@ -178,7 +224,6 @@ function appendTextItem( function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() - const lastNarrationChannel = new Map() // Stable per-run counters for React keys. The Nth top-level text run / Nth // mothership group keeps the same key across re-parses (text runs and groups // are append-only at the top level), so React never remounts the streaming @@ -208,8 +253,8 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // Top-level (mothership) tool calls render in a collapsible group. Reuse that // group only while it is still the most recent segment so consecutive tools - // stay together; once any other segment (main text, a spawned subagent, - // thinking, etc.) breaks the run, the next tool opens a fresh group below it + // stay together; once another visible segment (main text or a spawned + // subagent) breaks the run, the next tool opens a fresh group below it // instead of jumping back up into the original one. This keeps the mothership's // tools and prose interleaved in the order they actually happened. const ensureMothership = (): AgentGroupSegment => { @@ -274,7 +319,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - if (block.type === 'subagent_text' || block.type === 'subagent_thinking') { + // Thinking is intentionally absent from the transcript. Ignore both lanes + // so rollout-skewed or replayed streams cannot surface reasoning or affect + // layout differently from the persisted message, which strips it. + if (block.type === 'thinking' || block.type === 'subagent_thinking') continue + + if (block.type === 'subagent_text') { if (!block.content || !block.spanId) continue let g = groupsBySpanId.get(block.spanId) // Out-of-order safety: content can arrive before its subagent-start block @@ -285,19 +335,10 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { } if (!g) continue g.isDelegating = false - appendTextItem( - g, - block.content, - block.type === 'subagent_thinking' ? 'thinking' : 'assistant', - lastNarrationChannel - ) + appendTextItem(g, block.content) continue } - // Main-agent thinking is intentionally not rendered. The reasoning is still - // reduced and persisted upstream — this is a display-only omission. - if (block.type === 'thinking') continue - if (block.type === 'text') { if (!block.content) continue if (block.subagent && block.spanId) { @@ -306,7 +347,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) if (g) { g.isDelegating = false - appendTextItem(g, block.content, 'assistant', lastNarrationChannel) + appendTextItem(g, block.content) continue } } @@ -334,8 +375,8 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // Show the working/delegating spinner from span open until the agent // emits its first content or tool (or ends). The legacy path derived this // from the dispatch tool_call, which the span path absorbs, so we set it - // here. It is cleared in the subagent_text/subagent_thinking, scoped text, - // tool_call, and subagent_end branches. + // here. It is cleared in the subagent_text, scoped text, tool_call, and + // subagent_end branches; suppressed thinking leaves it unchanged. g.isDelegating = true g.isOpen = true continue @@ -435,8 +476,15 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() - const lastNarrationChannel = new Map() let activeGroupKey: string | null = null + // Run-ordinal keys, mirroring parseBlocksWithSpanTree. A turn starts in this + // parser and flips to the span-tree parser when the first spanId-carrying + // block arrives; segments that exist in both must keep the SAME React key + // across that flip or their subtrees remount mid-stream (group re-expands, + // text re-fades). Block-index text keys and position-based mothership ids + // diverge from the span-tree scheme; run ordinals match it. + let textRun = 0 + let mothershipRun = 0 const groupKey = (name: string, parentToolCallId: string | undefined) => parentToolCallId ? `${name}:${parentToolCallId}` : `${name}:legacy` @@ -463,9 +511,14 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { type: 'agent_group', // Canonical key = the dispatch tool call id, identical to the span-tree // parser, so a transcript that gains span ids (or a DB reload) keeps the - // same React key and never remounts. Orphans (no dispatch tool) keep the - // position-based legacy id. - id: parentToolCallId ? `agent-${parentToolCallId}` : `agent-${key}-${segments.length}`, + // same React key and never remounts. The mothership group uses the same + // run-ordinal id as the span-tree parser for the same reason. Orphans + // (no dispatch tool, not mothership) keep the position-based legacy id. + id: parentToolCallId + ? `agent-${parentToolCallId}` + : name === 'mothership' + ? `agent-mothership-${mothershipRun++}` + : `agent-${key}-${segments.length}`, agentName: name, agentLabel: resolveAgentLabel(name), items: [], @@ -502,25 +555,16 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - if (block.type === 'subagent_text' || block.type === 'subagent_thinking') { + // See the span-tree parser: thinking is neither visible nor allowed to + // influence grouping because it is absent from persisted transcripts. + if (block.type === 'thinking' || block.type === 'subagent_thinking') continue + + if (block.type === 'subagent_text') { if (!block.content) continue const g = findGroupForSubagentChunk(block.parentToolCallId) if (!g) continue g.isDelegating = false - appendTextItem( - g, - block.content, - block.type === 'subagent_thinking' ? 'thinking' : 'assistant', - lastNarrationChannel - ) - continue - } - - if (block.type === 'thinking') { - // Main-agent thinking is not rendered, but it still breaks open subagent - // lanes so later chunks don't merge across it (display-only omission). - if (!block.content?.trim()) continue - flushLanes() + appendTextItem(g, block.content) continue } @@ -530,7 +574,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId)) if (g) { g.isDelegating = false - appendTextItem(g, block.content, 'assistant', lastNarrationChannel) + appendTextItem(g, block.content) continue } } @@ -539,7 +583,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { if (last?.type === 'text') { last.content += block.content } else { - segments.push({ type: 'text', id: `text-${i}`, content: block.content }) + segments.push({ type: 'text', id: `text-${textRun++}`, content: block.content }) } continue } @@ -666,6 +710,25 @@ export function assistantMessageHasRenderableContent( return segments.length > 0 } +/** True when the transcript is already rendering an executing tool row. */ +export function assistantMessageHasVisibleExecutingTool(blocks: ContentBlock[]): boolean { + const subagentDispatchCallIds = new Set() + for (const block of blocks) { + if (block.type === 'subagent' && block.parentToolCallId) { + subagentDispatchCallIds.add(block.parentToolCallId) + } + } + + return blocks.some((block) => { + const toolCall = block.toolCall + if (!toolCall || toolCall.status !== 'executing') return false + if (isHiddenToolCall(toolCall.name)) return false + if (toolCall.name === ReadTool.id && isToolResultRead(toolCall.params)) return false + if (SUBAGENT_KEYS.has(toolCall.name)) return false + return !subagentDispatchCallIds.has(toolCall.id) + }) +} + export function shouldSmoothTextSegment({ isStreaming, segmentIndex, @@ -678,10 +741,34 @@ 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' + ) +} + interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string isStreaming: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onPhaseChange?: (phase: MessagePhase) => void } @@ -690,6 +777,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + questionAnswers, onOptionSelect, onPhaseChange, }: MessageContentProps) { @@ -700,6 +788,11 @@ function MessageContentInner({ const handleTrailingRevealChange = useCallback((revealing: boolean) => { setTrailingRevealing(revealing) }, []) + const [trailingStreamActivity, setTrailingStreamActivity] = useState(false) + const handleTrailingStreamActivityChange = useCallback((active: boolean) => { + setTrailingStreamActivity(active) + }, []) + const [isStreamIdle, setIsStreamIdle] = useState(false) const segments: MessageSegment[] = parsed.length > 0 @@ -707,6 +800,21 @@ function MessageContentInner({ : fallbackContent?.trim() ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }] : [] + 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. + useLayoutEffect(() => { + if (!isStreaming) { + setIsStreamIdle(false) + return + } + + setIsStreamIdle(false) + const timeout = setTimeout(() => setIsStreamIdle(true), STREAM_IDLE_DELAY_MS) + return () => clearTimeout(timeout) + }, [visibleStreamActivityKey, isStreaming]) const lastSegment = segments[segments.length - 1] const hasTrailingTextSegment = lastSegment?.type === 'text' @@ -730,16 +838,18 @@ function MessageContentInner({ return null } - const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped' - - // Deterministic "between steps" signal: the turn is still streaming, nothing - // is actively running (a running tool/subagent renders its own spinner), and - // no trailing text is being revealed. Derived from explicit node state rather - // than guessing from the shape of the last segment. - const hasRunningWork = blocks.some( - (b) => b.toolCall?.status === 'executing' || (b.type === 'subagent' && b.endedAt === undefined) - ) - const showTrailingThinking = phase === 'streaming' && !hasTrailingContent && !hasRunningWork + // 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. + const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) + const showTrailingThinking = shouldShowTrailingThinking({ + isStreaming: phase === 'streaming', + isStreamIdle, + isRenderingStream: trailingStreamActivity, + hasExecutingTool, + lastSegmentType: lastSegment.type, + }) return (
@@ -755,11 +865,15 @@ function MessageContentInner({ segmentIndex: i, segmentCount: segments.length, })} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ i === segments.length - 1 ? handleTrailingRevealChange : undefined } + onStreamActivityChange={ + i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined + } /> ) case 'agent_group': { @@ -796,11 +910,7 @@ function MessageContentInner({ ) } })} - {showTrailingThinking && ( -
- -
- )} + {showTrailingThinking && }
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 767575849b9..4de6f1fe0c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -30,6 +30,9 @@ const TOOL_ICONS: Record = { glob: FolderCode, grep: Search, read: File, + mv: FolderCode, + cp: Layout, + mkdir: FolderCode, search_online: Search, scrape_page: Search, get_page_contents: Search, @@ -38,6 +41,7 @@ const TOOL_ICONS: Record = { manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, + run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, workspace_file: File, @@ -51,12 +55,16 @@ const TOOL_ICONS: Record = { auth: Integration, knowledge: Database, knowledge_base: Database, + search_knowledge_base: Database, table: TableIcon, + query_user_table: TableIcon, scheduled_task: Calendar, job: Calendar, agent: AgentIcon, custom_tool: Wrench, research: Search, + scout: Search, + search: Search, context_compaction: Asterisk, open_resource: Eye, file: File, 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 951eb17da98..47c406f3bbc 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 @@ -20,7 +20,11 @@ import { MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' -import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +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 { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -89,6 +93,14 @@ const ROW_HEIGHT_ESTIMATE = { */ const OVERSCAN = 6 +/** + * How close to the bottom (px) the transcript must be to count as pinned for + * re-pinning across container resizes. Covers the fractional sub-pixel gap a + * DPR-scaled `scrollTop` can leave, without capturing a user who deliberately + * 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 @@ -163,6 +175,8 @@ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean precedingUserContent?: string + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -172,6 +186,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, precedingUserContent, + questionAnswers, rowClassName, onOptionSelect, onAnimatingChange, @@ -197,7 +212,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - const showActions = phase === 'settled' && (message.content || hasAnyBlocks) + // A message that ends with a question card is an input surface, not a + // reactable assistant turn: no copy/thumbs row beneath the card, whether + // the card is awaiting answers or collapsed to its recap. + const endsWithQuestion = trimmedContent.endsWith('') + const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks) return (
@@ -205,6 +224,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onPhaseChange={setPhase} /> @@ -270,6 +290,32 @@ export function MothershipChat({ const hasMessages = messages.length > 0 + /** + * Keep a bottom-pinned transcript pinned when the scroll container resizes. + * Growing or shrinking the multi-line input (or resizing the panel/window) + * changes the container height while `scrollTop` stays put, which silently + * unpins the chat from the bottom — the last message slides behind the + * input. Pinned-ness is sampled on every scroll (before the resize lands), + * so a user who scrolled up is never yanked back down. + */ + useEffect(() => { + const el = scrollElementRef.current + if (!el) return + let wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD + const onScroll = () => { + wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD + } + const observer = new ResizeObserver(() => { + if (wasAtBottom) el.scrollTop = el.scrollHeight - el.clientHeight + }) + el.addEventListener('scroll', onScroll, { passive: true }) + observer.observe(el) + return () => { + el.removeEventListener('scroll', onScroll) + observer.disconnect() + } + }, []) + /** * Stable per-row identity for virtualizer measurement caching and React * reconciliation. User rows key on their message id; assistant rows key on @@ -303,6 +349,34 @@ export function MothershipChat({ return out }, [messages]) + /** + * Pairs each assistant question card with the user message that answered it + * (strict `Prompt — Answer` match). The paired user message is hidden — the + * answered card IS the user turn — and the assistant row renders the card + * as a recap with these answers, both live and after reload. + */ + const questionPairing = useMemo(() => { + const answersByIndex: Array = [] + const hiddenUserByIndex: Array = [] + for (const [index, message] of messages.entries()) { + if (message.role !== 'assistant') continue + // Check the answering user message BEFORE scanning content: a pairing + // needs one anyway, and this skips the O(content) `includes` scan over + // the still-growing streaming message (always the last row) on every + // snapshot flush. + const next = messages[index + 1] + if (!next || next.role !== 'user' || !next.content) continue + if (!message.content?.includes('')) continue + const questions = parseLastQuestionTag(message.content) + if (!questions) continue + const answers = parseQuestionAnswerMessage(questions, next.content) + if (!answers) continue + answersByIndex[index] = answers + hiddenUserByIndex[index + 1] = true + } + return { answersByIndex, hiddenUserByIndex } + }, [messages]) + /** * Always keep the last row in the rendered window. It is the live/streaming * row; unmounting it (by scrolling far enough up that it leaves the overscan @@ -434,19 +508,22 @@ export function MothershipChat({ style={{ transform: `translateY(${virtualItem.start}px)` }} > {msg.role === 'user' ? ( - + questionPairing.hiddenUserByIndex[index] ? null : ( + + ) ) : ( { qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 43cdbef772a..39abdc7b359 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -33,6 +33,7 @@ const PORTABLE_KIND_TO_ID_FIELD = { workflow: 'workflowId', logs: 'executionId', skill: 'skillId', + mcp: 'serverId', integration: 'blockType', slash_command: 'command', } as const satisfies Partial> @@ -220,6 +221,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext { return { kind: 'logs', executionId: link.id, label: link.label } case 'skill': return { kind: 'skill', skillId: link.id, label: link.label } + case 'mcp': + return { kind: 'mcp', serverId: link.id, label: link.label } case 'integration': return { kind: 'integration', blockType: link.id, label: link.label } case 'slash_command': diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index b3d9cb78edd..f971a60e332 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { cn } from '@sim/emcn' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -71,14 +71,25 @@ export function PromptEditor({ onArrowUpOnEmpty, }: PromptEditorProps) { const { textareaRef, value } = editor + const scrollerRef = useRef(null) + /** + * Autosize: grow the textarea to its full content height; the scroller caps + * the visible height and scrolls textarea + overlay together natively. The + * scroller's box is locked while the textarea collapses to `auto` for + * measurement — the scrollHeight read forces a layout at the collapsed + * height, and without the lock that transient layout grows the chat scroll + * container, letting the browser clamp a bottom-pinned transcript upward by + * the input's grown height on every multi-line edit. + */ useLayoutEffect(() => { const textarea = textareaRef.current if (!textarea) return - // Grow the textarea to its full content height; the scroller caps the - // visible height and scrolls textarea + overlay together natively. + const scroller = scrollerRef.current + if (scroller) scroller.style.height = `${scroller.offsetHeight}px` textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` + if (scroller) scroller.style.height = '' }, [value, textareaRef]) useEffect(() => { @@ -171,7 +182,11 @@ export function PromptEditor({ }, [value, editor.contexts]) return ( -
+
{/* Sizer for textarea + overlay: the textarea grows to full content height and the overlay fills it via `inset-0`, so both are flow children of the same scroller and co-scroll natively. */} @@ -214,7 +229,9 @@ export function PromptEditor({ ({ useSkills: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) })) vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) })) vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 49aeededfd4..3cc377bd39a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -24,6 +24,7 @@ import { restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { type McpServer, useMcpServers } from '@/hooks/queries/mcp' import { type SkillDefinition, useSkills } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -155,6 +156,11 @@ export function usePromptEditor({ onPasteFiles, }: UsePromptEditorProps) { const { data: skills = [] } = useSkills(workspaceId) + const { data: allMcpServers = [] } = useMcpServers(workspaceId) + const mcpServers = useMemo( + () => allMcpServers.filter((server) => server.enabled && server.workspaceId === workspaceId), + [allMcpServers, workspaceId] + ) const [value, setValueState] = useState(initialValue) const valueRef = useRef(value) @@ -224,6 +230,7 @@ export function usePromptEditor({ const skillAutoMention = useSkillAutoMention({ skills, + mcpServers, setSelectedContexts: contextManagement.setSelectedContexts, }) @@ -272,8 +279,8 @@ export function usePromptEditor({ valueRef.current = converted setValueState(converted) } - seedRef.current = skills.length > 0 ? null : converted - }, [skills.length, applyAutoMentions]) + seedRef.current = skills.length > 0 || mcpServers.length > 0 ? null : converted + }, [skills.length, mcpServers.length, applyAutoMentions]) const existingResourceKeys = useMemo(() => { const keys = new Set() @@ -444,6 +451,32 @@ export function usePromptEditor({ [textareaRef, addContextNotified] ) + const handleMcpSelect = useCallback( + (server: McpServer) => { + const textarea = textareaRef.current + if (textarea) { + const currentValue = valueRef.current + const range = slashRangeRef.current + const insertAt = range?.start ?? textarea.selectionStart ?? currentValue.length + const end = range?.end ?? insertAt + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) + const insertText = `${needsSpaceBefore ? ' ' : ''}${SKILL_CHIP_TRIGGER}${server.name} ` + const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(end)}` + const newPos = insertAt + insertText.length + + pendingCursorRef.current = newPos + valueRef.current = newValue + slashRangeRef.current = null + setSlashQuery(null) + dismissedSlashStartRef.current = null + setValueState(newValue) + } + + addContextNotified({ kind: 'mcp', serverId: server.id, label: server.name }) + }, + [textareaRef, addContextNotified] + ) + /** * Only reachable via Radix's own dismiss detection (outside click / * Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass @@ -1010,6 +1043,8 @@ export function usePromptEditor({ /** @internal Wiring consumed by the {@link PromptEditor} view. */ skills, /** @internal */ + mcpServers, + /** @internal */ availableResources, /** @internal */ mentionQuery, @@ -1026,6 +1061,8 @@ export function usePromptEditor({ /** @internal */ handleSkillSelect, /** @internal */ + handleMcpSelect, + /** @internal */ handlePlusMenuClose, /** @internal */ handleSkillsMenuClose, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx index 9d43b5bd773..93c892bab5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn' -import { AgentSkillsIcon } from '@/components/icons' +import { AgentSkillsIcon, McpIcon } from '@/components/icons' +import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' /** @@ -24,8 +25,12 @@ export interface SkillsMenuHandle { interface SkillsMenuDropdownProps { /** Skills available in the current workspace. */ skills: SkillDefinition[] + /** Connected MCP servers available in the current workspace. */ + mcpServers: McpServer[] /** Called when a skill row is chosen (click / keyboard). */ onSkillSelect: (skill: SkillDefinition) => void + /** Called when an MCP server row is chosen. */ + onMcpSelect: (server: McpServer) => void /** Called when the menu closes so the host can reset slash state. */ onClose: () => void /** Host textarea — focus is restored to it on close. */ @@ -44,7 +49,16 @@ interface SkillsMenuDropdownProps { */ export const SkillsMenuDropdown = React.memo( React.forwardRef(function SkillsMenuDropdown( - { skills, onSkillSelect, onClose, textareaRef, pendingCursorRef, slashQuery }, + { + skills, + mcpServers, + onSkillSelect, + onMcpSelect, + onClose, + textareaRef, + pendingCursorRef, + slashQuery, + }, ref ) { const [open, setOpen] = useState(false) @@ -52,14 +66,18 @@ export const SkillsMenuDropdown = React.memo( const [activeIndex, setActiveIndex] = useState(0) const contentRef = useRef(null) - const filteredSkills = useMemo(() => { + const filteredItems = useMemo(() => { const q = (slashQuery ?? '').toLowerCase().trim() - if (!q) return skills - return skills.filter((skill) => skill.name.toLowerCase().includes(q)) - }, [skills, slashQuery]) - - const filteredSkillsRef = useRef(filteredSkills) - filteredSkillsRef.current = filteredSkills + const items = [ + ...skills.map((skill) => ({ kind: 'skill' as const, item: skill })), + ...mcpServers.map((server) => ({ kind: 'mcp' as const, item: server })), + ] + if (!q) return items + return items.filter(({ item }) => item.name.toLowerCase().includes(q)) + }, [skills, mcpServers, slashQuery]) + + const filteredItemsRef = useRef(filteredItems) + filteredItemsRef.current = filteredItems const activeIndexRef = useRef(activeIndex) activeIndexRef.current = activeIndex @@ -74,12 +92,13 @@ export const SkillsMenuDropdown = React.memo( }, []) const handleSelect = useCallback( - (skill: SkillDefinition) => { - onSkillSelect(skill) + (target: (typeof filteredItems)[number]) => { + if (target.kind === 'skill') onSkillSelect(target.item) + else onMcpSelect(target.item) setOpen(false) setActiveIndex(0) }, - [onSkillSelect] + [onSkillSelect, onMcpSelect] ) const handleSelectRef = useRef(handleSelect) @@ -91,7 +110,7 @@ export const SkillsMenuDropdown = React.memo( open: doOpen, close: doClose, moveActive: (delta: number) => { - const items = filteredSkillsRef.current + const items = filteredItemsRef.current if (items.length === 0) return setActiveIndex((i) => { const next = i + delta @@ -101,7 +120,7 @@ export const SkillsMenuDropdown = React.memo( }) }, selectActive: () => { - const items = filteredSkillsRef.current + const items = filteredItemsRef.current if (items.length === 0) return false const target = items[activeIndexRef.current] ?? items[0] if (!target) return false @@ -120,12 +139,12 @@ export const SkillsMenuDropdown = React.memo( // Sync DOM scroll to the keyboard-highlighted row. useEffect(() => { - if (filteredSkills.length === 0) return + if (filteredItems.length === 0) return const row = contentRef.current?.querySelector( `[data-filtered-idx="${activeIndex}"]` ) row?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex, filteredSkills]) + }, [activeIndex, filteredItems]) const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen) @@ -179,30 +198,30 @@ export const SkillsMenuDropdown = React.memo( onOpenAutoFocus={handleOpenAutoFocus} >
- {filteredSkills.length > 0 ? ( - filteredSkills.map((skill, index) => { + {filteredItems.length > 0 ? ( + filteredItems.map((target, index) => { const isActive = index === activeIndex return ( ) }) ) : (
- No skills + No skills or MCP servers
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts index 8a962686df2..6aa5006ba54 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -3,6 +3,7 @@ import { escapeRegex, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -15,6 +16,12 @@ import type { ChatContext } from '@/stores/panel' const WORD_BOUNDARY_REGEX = /^[\s.,;:!?(){}[\]"'`/\\<>\n]$/ type SkillContext = Extract +type McpContext = Extract +type SlashContext = SkillContext | McpContext + +function slashContextKey(context: SlashContext): string { + return context.kind === 'skill' ? `skill:${context.skillId}` : `mcp:${context.serverId}` +} /** * A skill trigger — the typed `/` or the stored EM SPACE sentinel — only counts @@ -32,6 +39,8 @@ function isTriggerPrefixAt(text: string, index: number): boolean { interface UseSkillAutoMentionProps { /** Skills available in the current workspace. */ skills: SkillDefinition[] + /** MCP servers available in the current workspace. */ + mcpServers: McpServer[] /** Setter for the host's selected contexts. */ setSelectedContexts: React.Dispatch> } @@ -58,14 +67,18 @@ interface ProcessChangeArgs { * speech-to-text. Swaps each matched typed `/` for the sentinel in the * returned string and registers any matched skill contexts. */ -export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAutoMentionProps) { +export function useSkillAutoMention({ + skills, + mcpServers, + setSelectedContexts, +}: UseSkillAutoMentionProps) { /** * Matcher built from skill names, longest-first so `/my-skill-extended` * wins over `/my-skill`. The trailing guard rejects partial matches that * continue into more name characters. */ const matcher = useMemo(() => { - const byName = new Map() + const byName = new Map() for (const skill of skills) { byName.set(skill.name.toLowerCase(), { kind: 'skill', @@ -73,7 +86,15 @@ export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAut label: skill.name, }) } - const names = [...skills].map((s) => s.name).sort((a, b) => b.length - a.length) + for (const server of mcpServers) { + const key = server.name.toLowerCase() + if (!byName.has(key)) { + byName.set(key, { kind: 'mcp', serverId: server.id, label: server.name }) + } + } + const names = [...byName.values()] + .map((context) => context.label) + .sort((a, b) => b.length - a.length) if (names.length === 0) return { regex: null as RegExp | null, byName } // Match either trigger: the typed '/' or the stored sentinel, so both fresh // input and pasted/restored chips resolve. The trigger group is the match's @@ -81,19 +102,24 @@ export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAut const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})` const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])` return { regex: new RegExp(pattern, 'gi'), byName } - }, [skills]) + }, [skills, mcpServers]) const matcherRef = useRef(matcher) matcherRef.current = matcher const mergeContexts = useCallback( - (additions: SkillContext[]) => { + (additions: SlashContext[]) => { if (additions.length === 0) return setSelectedContexts((prev) => { const existing = new Set( - prev.filter((c): c is SkillContext => c.kind === 'skill').map((c) => c.skillId) + prev + .filter( + (context): context is SlashContext => + context.kind === 'skill' || context.kind === 'mcp' + ) + .map(slashContextKey) ) - const fresh = additions.filter((c) => !existing.has(c.skillId)) + const fresh = additions.filter((context) => !existing.has(slashContextKey(context))) return fresh.length > 0 ? [...prev, ...fresh] : prev }) }, @@ -152,7 +178,7 @@ export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAut if (!regex || !text) return text regex.lastIndex = 0 - const additions: SkillContext[] = [] + const additions: SlashContext[] = [] const seen = new Set() const slashIndices: number[] = [] let match: RegExpExecArray | null @@ -164,8 +190,9 @@ export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAut // Rewrite every confirmed typed '/' (even a repeated skill) so duplicate // tokens chip consistently; tokens already on the sentinel need no edit. if (text[index] === '/') slashIndices.push(index) - if (seen.has(context.skillId)) continue - seen.add(context.skillId) + const key = slashContextKey(context) + if (seen.has(key)) continue + seen.add(key) additions.push(context) } mergeContexts(additions) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index 77e90582205..b5bb7ace728 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -52,7 +52,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men for (const rawCtx of contexts) { if (!rawCtx.label) continue const ctx = withResolvedBlockType(rawCtx) - const prefix = ctx.kind === 'skill' ? '/' : '@' + const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@' const token = `${prefix}${ctx.label}` const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g') let match: RegExpExecArray | null diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 33b5f2c5f2f..9776acdaf7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -144,7 +144,7 @@ export function Home({ chatId, userName, userId }: HomeProps) { filename: `${seed.workflowName}.json`, workspaceId, nameOverride: seed.workflowName, - descriptionOverride: seed.workflowDescription || 'Imported from landing template', + descriptionOverride: seed.workflowDescription || undefined, createWorkflow: async ({ name, description, workspaceId }) => { return requestJson(createWorkflowContract, { body: { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts new file mode 100644 index 00000000000..5526c48aca8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invalidateResourceQueries: vi.fn(), + removeWorkflowFromActiveCache: vi.fn(), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry', + () => ({ invalidateResourceQueries: mocks.invalidateResourceQueries }) +) +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + removeWorkflowFromActiveCache: mocks.removeWorkflowFromActiveCache, +})) + +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' + +function removeEvent(type: 'workflow' | 'file', id: string): PersistedStreamEventEnvelope { + return { + type: 'resource', + v: 1, + seq: 1, + ts: '', + stream: { streamId: 's', cursor: '1' }, + payload: { op: 'remove', resource: { type, id, title: id } }, + } as PersistedStreamEventEnvelope +} + +describe('handleResourceEvent removal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('closes a deleted workflow tab and removes it from the established workflow cache', () => { + const deps = makeStreamLoopDeps() + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, removeEvent('workflow', 'wf-1')) + + expect(deps.removeResource).toHaveBeenCalledWith('workflow', 'wf-1') + expect(mocks.removeWorkflowFromActiveCache).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'wf-1' + ) + expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'workflow', + 'wf-1' + ) + }) + + it('closes other resource tabs through the same remove event path', () => { + const deps = makeStreamLoopDeps() + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, removeEvent('file', 'file-1')) + + expect(deps.removeResource).toHaveBeenCalledWith('file', 'file-1') + expect(mocks.removeWorkflowFromActiveCache).not.toHaveBeenCalled() + expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'file', + 'file-1' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index b94384a8b34..03af5c60165 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -11,6 +11,7 @@ import { } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' type ResourceEvent = Extract< @@ -46,13 +47,12 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven const resource = payload.resource if (payload.op === MothershipStreamV1ResourceOp.remove) { - removeResource(resource.type as MothershipResourceType, resource.id) - invalidateResourceQueries( - queryClient, - workspaceId, - resource.type as MothershipResourceType, - resource.id - ) + const resourceType = resource.type as MothershipResourceType + removeResource(resourceType, resource.id) + if (resourceType === 'workflow') { + removeWorkflowFromActiveCache(queryClient, workspaceId, resource.id) + } + invalidateResourceQueries(queryClient, workspaceId, resourceType, resource.id) onResourceEvent?.() return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index 1a760f149f1..fad976acbbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -21,6 +21,7 @@ import { dispatchStreamEvent } from './dispatch-stream-event' import { createStreamLoopContext, type StreamLoopContext } from './stream-context' import { makeStreamLoopDeps, ref } from './stream-test-helpers' import type { ToolNode } from './turn-model' +import { contentBlocksToModel, modelToContentBlocks } from './turn-model-serialize' let seq = 0 function toolEnv(payload: Record): PersistedStreamEventEnvelope { @@ -124,3 +125,127 @@ describe('tool events (dispatch → model + side effects)', () => { expect(toolNode(ctx, 'wf-1').status).toBe('success') }) }) + +describe('integration gateway (full wire sequence → published snapshot)', () => { + const GATEWAY = 'call_integration_tool' + const CALL_ID = 'ig-1' + + const generating = () => + toolEnv({ + phase: 'call', + executor: 'go', + mode: 'sync', + toolCallId: CALL_ID, + toolName: GATEWAY, + status: 'generating', + }) + + const argsDelta = (argumentsDelta: string) => + toolEnv({ + phase: 'args_delta', + executor: 'go', + mode: 'sync', + toolCallId: CALL_ID, + toolName: GATEWAY, + argumentsDelta, + }) + + const gatewayFinalCall = () => + toolEnv({ + phase: 'call', + executor: 'go', + mode: 'sync', + toolCallId: CALL_ID, + toolName: GATEWAY, + arguments: { + toolId: 'gmail_read_v2', + description: 'Read recent emails', + arguments: { maxResults: 5 }, + }, + }) + + const resolvedOperationCall = () => + toolEnv({ + phase: 'call', + executor: 'sim', + mode: 'async', + toolCallId: CALL_ID, + toolName: 'gmail_read_v2', + arguments: { maxResults: 5, credentialId: 'cred-1' }, + }) + + /** The exact toolCall snapshot the browser publishes for this row. */ + function publishedToolCall(ctx: StreamLoopContext) { + const blocks = modelToContentBlocks(ctx.state.model) + const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID) + expect(block?.toolCall).toBeDefined() + return block!.toolCall! + } + + it('brands the row from streamed args while generating, then rebinds to the resolved operation', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + + // Provisional frame: neutral label, never the humanized gateway name. + dispatchStreamEvent(ctx, generating()) + expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration') + + // toolId alone brands only the icon (row component); text stays neutral. + dispatchStreamEvent(ctx, argsDelta('{"toolId":"gmail_read_v2",')) + expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration') + expect(publishedToolCall(ctx).streamingArgs).toContain('"toolId":"gmail_read_v2"') + + // The model-authored activity phrase becomes the row text as it completes. + dispatchStreamEvent(ctx, argsDelta('"description":"Read recent emails",')) + expect(publishedToolCall(ctx).displayTitle).toBe('Read recent emails') + + dispatchStreamEvent(ctx, argsDelta('"arguments":{"maxResults":5}}')) + dispatchStreamEvent(ctx, gatewayFinalCall()) + expect(publishedToolCall(ctx)).toEqual( + expect.objectContaining({ + name: GATEWAY, + displayTitle: 'Read recent emails', + }) + ) + + // Second authoritative frame (same call id): rebind to the exact operation. + dispatchStreamEvent(ctx, resolvedOperationCall()) + const rebound = publishedToolCall(ctx) + expect(rebound).toEqual( + expect.objectContaining({ + name: 'gmail_read_v2', + displayTitle: 'Read recent emails', + integrationDescription: 'Read recent emails', + params: { maxResults: 5, credentialId: 'cred-1' }, + }) + ) + expect(rebound.streamingArgs).toBeUndefined() + + dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2')) + expect(publishedToolCall(ctx)).toEqual( + expect.objectContaining({ + name: 'gmail_read_v2', + status: 'success', + displayTitle: 'Read recent emails', + }) + ) + }) + + it('keeps the rebound branding across a snapshot rebuild (reconnect round-trip)', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + dispatchStreamEvent(ctx, generating()) + dispatchStreamEvent(ctx, gatewayFinalCall()) + dispatchStreamEvent(ctx, resolvedOperationCall()) + dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2')) + + const rebuilt = contentBlocksToModel(modelToContentBlocks(ctx.state.model)) + const blocks = modelToContentBlocks(rebuilt) + const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID) + expect(block?.toolCall).toEqual( + expect.objectContaining({ + name: 'gmail_read_v2', + status: 'success', + displayTitle: 'Read recent emails', + }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index b49871bfbdf..4ca4012f138 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -22,6 +22,9 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' +/** Minimum spacing between text-driven snapshot flushes (see flushText). */ +const MIN_TEXT_FLUSH_INTERVAL_MS = 50 + export type ActiveTurn = { userMessageId: string assistantMessageId: string @@ -31,6 +34,25 @@ export type ActiveTurn = { export interface StreamLoopOptions { preserveExistingState?: boolean + /** + * The real wire cursor the preserved snapshot corresponds to. The + * preserve-state rebuild assigns synthetic seqs (1..M, M = synthesized + * envelope count) — a unit unrelated to wire seq — while the reducer's + * `seq <= lastSeq` idempotency guard compares against incoming REAL seqs. + * Re-baselining lastSeq to this cursor keeps the guard in wire units so a + * turn with many tool/subagent blocks but few wire events can never have + * M >= afterCursor+1 silently drop the first resumed events. + */ + resumeCursor?: string + /** + * Batch-replay mode: suppress every intermediate snapshot write and publish + * ONE atomic flush when the stream ends (processSSEStream calls forceFlush). + * A reconnect replay re-derives content the user already saw — rendering it + * incrementally collapses the visible message to a prefix and re-arms the + * smooth-reveal/fade over text that was already on screen. With one terminal + * flush the rendered content only ever appends. + */ + deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean @@ -47,6 +69,8 @@ export interface StreamLoopState { sawStreamError: boolean sawCompleteEvent: boolean scheduledTextFlushFrame: number | null + /** Trailing timer for the min-interval text-flush gate (see flushText). */ + scheduledTextFlushTimer: ReturnType | null } export interface StreamEventScope { @@ -135,6 +159,8 @@ export interface StreamLoopOps { isStale: () => boolean flush: () => void flushText: () => void + /** Real flush that bypasses `deferFlushes` — the batch-replay terminal flush. */ + forceFlush: () => void } export interface StreamLoopContext { @@ -165,13 +191,27 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext sawStreamError: false, sawCompleteEvent: false, scheduledTextFlushFrame: null, + scheduledTextFlushTimer: null, + } + + if (preserveState) { + // Convert the rebuilt model's synthetic lastSeq back into wire units (see + // StreamLoopOptions.resumeCursor). Without this, incoming real seqs are + // compared against a synthetic envelope count. + const resumeCursor = Number(deps.options.resumeCursor) + if (Number.isFinite(resumeCursor) && resumeCursor >= 0) { + state.model.lastSeq = resumeCursor + } } const isStale = () => (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) || deps.options.shouldContinue?.() === false - if (!preserveState && !isStale()) { + // Deferred (batch-replay) runs keep the previous refs intact until the + // terminal flush: they are what a mid-replay stop persists, and clearing + // them buys nothing when no intermediate flush will read them. + if (!preserveState && !isStale() && deps.options.deferFlushes !== true) { deps.streamingContentRef.current = '' deps.streamingBlocksRef.current = [] } @@ -187,7 +227,8 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext captureRevealedSimKeys( deps.revealedSimKeysRef.current, [deps.assistantId, state.streamRequestId], - modelContent + modelContent, + modelBlocks ) const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current if (!activeChatId) { @@ -241,23 +282,51 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext }) } + // Text flushes are the hot path (one per streamed chunk); every flush + // re-serializes the whole model and re-runs the transcript-wide memos + // downstream. The min-interval gate caps that at ~20 snapshots/sec — the + // visible pacing is owned by the smooth-text reveal, so a 50ms snapshot + // cadence is indistinguishable from per-frame. Tool/lifecycle flushes stay + // immediate, and they push the next text flush out via lastFlushAtMs. + let lastFlushAtMs = 0 + const flushAndStamp = () => { + lastFlushAtMs = Date.now() + flush() + } + const flushText = () => { + if (deps.options.deferFlushes === true) return if (isStale()) return - if (state.scheduledTextFlushFrame !== null) return + if (state.scheduledTextFlushFrame !== null || state.scheduledTextFlushTimer !== null) return if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - flush() + flushAndStamp() return } - state.scheduledTextFlushFrame = window.requestAnimationFrame(() => { - state.scheduledTextFlushFrame = null - flush() - }) + const scheduleFrame = () => { + state.scheduledTextFlushFrame = window.requestAnimationFrame(() => { + state.scheduledTextFlushFrame = null + flushAndStamp() + }) + } + const waitMs = MIN_TEXT_FLUSH_INTERVAL_MS - (Date.now() - lastFlushAtMs) + if (waitMs <= 0) { + scheduleFrame() + return + } + state.scheduledTextFlushTimer = setTimeout(() => { + state.scheduledTextFlushTimer = null + scheduleFrame() + }, waitMs) } const ops: StreamLoopOps = { isStale, - flush, + flush: () => { + if (deps.options.deferFlushes === true) return + flushAndStamp() + }, flushText, + forceFlush: flush, } return { state, ops, deps } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 72e1bd2dabc..6064956677e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -1,30 +1,26 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { + CallIntegrationTool, CrawlWebsite, + CreateFile, + CreateWorkflow, DeleteWorkflow, DeployApi, DeployChat, DeployMcp, + EditWorkflow, FunctionExecute, Glob, Grep, ManageCredential, - ManageCredentialOperation, ManageCustomTool, - ManageCustomToolOperation, ManageFolder, - ManageFolderOperation, ManageMcpTool, - ManageMcpToolOperation, ManageScheduledTask, - ManageScheduledTaskOperation, ManageSkill, - ManageSkillOperation, - MoveWorkflow, QueryLogs, Redeploy, - RenameWorkflow, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -34,7 +30,8 @@ import { WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' +import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' @@ -52,12 +49,15 @@ export const DEPLOY_TOOL_NAMES: Set = new Set([ Redeploy.id, ]) -export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id]) +export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id, 'mkdir', 'mv']) export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ - MoveWorkflow.id, - RenameWorkflow.id, + 'mv', + 'cp', DeleteWorkflow.id, + // Removed legacy tools, kept while their grace-period executors remain. + 'move_workflow', + 'rename_workflow', ]) export type StreamPayload = Record @@ -163,6 +163,25 @@ function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined return getWorkflowById(workspaceId, id)?.name } +function resolveTargetWorkflowName(args: Record | undefined): string | undefined { + const explicitName = stringParam(args?.workflowName) ?? stringParam(args?.name) + if (explicitName) return explicitName + + const registry = useWorkflowRegistry.getState() + return resolveWorkflowNameForDisplay(args?.workflowId ?? registry.hydration.workflowId) +} + +function resolveDeletedWorkflowTarget(workflowIds: unknown): string | undefined { + if (!Array.isArray(workflowIds) || workflowIds.length === 0) return undefined + const names = workflowIds + .map(resolveWorkflowNameForDisplay) + .filter((name): name is string => Boolean(name)) + if (names.length === 0) return undefined + if (workflowIds.length === 1) return names[0] + if (workflowIds.length === 2 && names.length === 2) return `${names[0]} and ${names[1]}` + return `${names[0]} and ${workflowIds.length - 1} more` +} + function resolveBlockNameForDisplay(blockId: unknown): string | undefined { const id = stringParam(blockId) if (!id) return undefined @@ -195,19 +214,37 @@ function resolveWorkspaceFileDisplayTitle( return undefined } -function resolveOperationDisplayTitle( - operation: unknown, - labels: Partial>, - fallback: string -): string { - const label = typeof operation === 'string' ? labels[operation] : undefined - return label ?? fallback -} - function functionExecuteTitle(title: string | undefined): string { return title ?? 'Running code' } +/** + * Row text for an integration-gateway tool call: the model-authored activity + * `description`, readable the moment it completes in the still-streaming + * argument buffer — the same pattern `read`/`workspace_file` use for their + * streaming VFS targets. The trusted integration branding is the ICON, which + * the row component derives deterministically from the streamed `toolId` (or + * the rebound operation name) via Sim's block registry — the text carries no + * integration name. After Go's authoritative frame rebinds the row to the + * exact operation (e.g. `gmail_read_v2`), the preserved + * `integrationDescription` keeps the same text. Returns undefined until a + * description is readable (callers fall back to the neutral gateway label). + */ +export function resolveIntegrationToolDisplayTitle(tool: { + name: string + args?: Record + streamingArgs?: string + integrationDescription?: string +}): string | undefined { + if (tool.name === CallIntegrationTool.id) { + const description = + stringParam(tool.args?.description) ?? + extractStreamingStringArgument(tool.streamingArgs, 'description')?.trim() + return description || undefined + } + return tool.integrationDescription +} + export function resolveToolDisplayTitle(name: string, args?: Record): string { // Cases that enrich the title with live workspace/block names from the client // stores. Everything else is resolved by the shared name+args resolver, which @@ -235,6 +272,16 @@ export function resolveToolDisplayTitle(name: string, args?: Record { + it('includes resource names as soon as they appear in streamed arguments', () => { + expect(resolveStreamingToolDisplayTitle('create_workflow', '{"name":"Lead Router"}')).toBe( + 'Creating Lead Router' + ) + expect( + resolveStreamingToolDisplayTitle( + 'manage_custom_tool', + '{"operation":"add","schema":{"function":{"name":"lookupWeather"}}}' + ) + ).toBe('Creating lookupWeather') + expect( + resolveStreamingToolDisplayTitle( + 'manage_folder', + '{"operation":"rename","path":"workflows/Old%20Name","name":"New Name"}' + ) + ).toBe('Renaming Old Name to New Name') + }) +}) + // A main-agent file delegation: trigger tool (main lane), subagent span, inner // workspace_file, span end, delegation result. function fileDelegationEvents(): PersistedStreamEventEnvelope[] { @@ -323,6 +344,48 @@ describe('modelToContentBlocks', () => { expect(blocksByType(blocks, 'subagent_end')).toHaveLength(0) expect(blocksByType(blocks, 'subagent')).toHaveLength(1) }) + + it('persists a completed compaction inside its subagent span', () => { + const sub: Scope = { + lane: 'subagent', + spanId: 'S1', + parentSpanId: 'main', + parentToolCallId: 'tc-workflow', + agentId: 'workflow', + } + const blocks = modelToContentBlocks( + build([ + env( + 1, + 'span', + { + kind: 'subagent', + event: 'start', + agent: 'workflow', + data: { tool_call_id: 'tc-workflow' }, + }, + sub + ), + env(2, 'run', { kind: 'compaction_start' }, sub), + env(3, 'run', { kind: 'compaction_done' }, sub), + ]) + ) + + const compaction = blocks.find( + (block) => block.type === 'tool_call' && block.toolCall?.name === 'context_compaction' + ) + expect(compaction).toEqual( + expect.objectContaining({ + spanId: 'S1', + parentSpanId: 'main', + toolCall: expect.objectContaining({ + calledBy: 'workflow', + displayTitle: 'Summarizing context', + status: 'success', + }), + }) + ) + }) }) describe('contentBlocksToModel round-trip', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 3e3b6cc9f6e..37df099dfc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -1,5 +1,6 @@ import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { + resolveIntegrationToolDisplayTitle, resolveStreamingToolDisplayTitle, resolveToolDisplayTitle, } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' @@ -38,10 +39,14 @@ function toolStatusToNode(status: ToolCallStatus): NodeStatus { /** * Resolves a tool row's display title with the same precedence the live handler - * used: the streaming-args title wins while args stream, then the arg-derived - * title, then the explicit `ui.title`. + * used: the integration gateway's model-authored activity description first + * (live even mid-argument-stream; the integration brand is the row icon), then + * the streaming-args title while args stream, then the arg-derived title, then + * the explicit `ui.title`. */ function toolDisplayTitle(node: ToolNode): string | undefined { + const integrationTitle = resolveIntegrationToolDisplayTitle(node) + if (integrationTitle) return integrationTitle const streamingTitle = node.streamingArgs ? resolveStreamingToolDisplayTitle(node.name, node.streamingArgs) : undefined @@ -126,6 +131,9 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { name: node.name, status: nodeToToolStatus(node.status), ...(displayTitle ? { displayTitle } : {}), + ...(node.integrationDescription + ? { integrationDescription: node.integrationDescription } + : {}), ...(node.args ? { params: node.args } : {}), ...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}), ...(node.result @@ -217,16 +225,31 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { // double-cast-allowed: synthetic replay envelope rebuilt from ContentBlocks for reduceEvent only; payloads are intentionally the minimal shape the reducer reads (no executor/mode), never provider-parsed or re-emitted on the wire }) as unknown as PersistedStreamEventEnvelope - const scopeFor = (block: ContentBlock): Record | undefined => - block.spanId + const scopeFor = (block: ContentBlock): Record | undefined => { + // Legacy/degraded snapshots (older persisted rows, pre-span stop payloads) + // can carry lane linkage without spanId. Fall back to the deterministic + // `span:${parentToolCallId}` id — the same convention reduceEvent uses for + // scope-less span starts — so the lane survives the rebuild instead of + // collapsing into the main lane (subagent text at root, flat layout). + const laneLinked = + block.type === 'subagent' || + block.type === 'subagent_text' || + block.type === 'subagent_thinking' || + Boolean(block.subagent) || + Boolean(block.toolCall?.calledBy) + const spanId = + block.spanId ?? + (laneLinked && block.parentToolCallId ? `span:${block.parentToolCallId}` : undefined) + return spanId ? { lane: 'subagent', - spanId: block.spanId, + spanId, ...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}), ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), ...(block.subagent ? { agentId: block.subagent } : {}), } : undefined + } for (const block of blocks) { if (block.type === 'subagent') { @@ -277,6 +300,11 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { arguments: tc.params, // Preserve a server-provided title that isn't derivable from args. ...(tc.displayTitle ? { ui: { title: tc.displayTitle } } : {}), + // Rebound gateway rows keep their model-authored activity phrase + // across a snapshot rebuild (the resolved args no longer carry it). + ...(tc.integrationDescription + ? { integrationDescription: tc.integrationDescription } + : {}), }, scopeFor(block), block.timestamp diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 85d40741490..4681ae8e402 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -433,7 +433,49 @@ describe('reduceEvent — error tag + compaction coverage', () => { (n) => n.kind === 'tool' && n.name === 'context_compaction' ) as ToolNode expect(compaction.status).toBe('success') - expect(compaction.uiTitle).toBe('Compacted context') + expect(compaction.uiTitle).toBe('Summarizing context') + }) + + it('pairs concurrent compactions only within their scoped subagent spans', () => { + const scopeA: Scope = { + lane: 'subagent', + spanId: 'S1', + parentSpanId: MAIN_SPAN, + parentToolCallId: 'tc-A', + agentId: 'workflow', + } + const scopeB: Scope = { + lane: 'subagent', + spanId: 'S2', + parentSpanId: MAIN_SPAN, + parentToolCallId: 'tc-B', + agentId: 'workflow', + } + const m = apply([ + envelope(1, 'run', { kind: 'compaction_start' }, scopeA), + envelope(2, 'run', { kind: 'compaction_start' }, scopeB), + envelope(3, 'run', { kind: 'compaction_done' }, scopeA), + ]) + + expect(agent(m, 'S1').agentId).toBe('workflow') + expect(agent(m, 'S2').agentId).toBe('workflow') + expect(tool(m, 'compaction:1')).toEqual( + expect.objectContaining({ + spanId: 'S1', + status: 'success', + uiTitle: 'Summarizing context', + }) + ) + expect(tool(m, 'compaction:2')).toEqual( + expect.objectContaining({ + spanId: 'S2', + status: 'running', + uiTitle: 'Summarizing context', + }) + ) + + reduceEvent(m, envelope(4, 'run', { kind: 'compaction_done' }, scopeB)) + expect(tool(m, 'compaction:2').status).toBe('success') }) }) @@ -484,3 +526,33 @@ describe('turn-terminal propagation', () => { expect(tool(m, 'tc-1').status).toBe('error') }) }) + +describe('reduceEvent — span-start owner reconciliation', () => { + it('corrects a nonempty mismatched provisional lane owner from the authoritative start', () => { + const model = createTurnModel() + // A content event races ahead of the span start; its scope names the + // FORWARDING caller (superagent), not the lane's real owner. + reduceEvent( + model, + envelope( + 1, + 'text', + { channel: 'assistant', text: 'early chunk' }, + { lane: 'subagent', spanId: 'S1', agentId: 'superagent', parentToolCallId: 'd1' } + ) + ) + reduceEvent( + model, + envelope( + 2, + 'span', + { kind: 'subagent', event: 'start', agent: 'workflow', data: { tool_call_id: 'd1' } }, + { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } + ) + ) + const laneId = model.agentBySpanId.get('S1') + const lane = laneId ? model.nodes.get(laneId) : undefined + if (!lane || lane.kind !== 'agent') throw new Error('expected agent lane for S1') + expect((lane as AgentNode).agentId).toBe('workflow') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index fde7fd7e1e4..2f634a46fe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -8,7 +8,10 @@ import { MothershipStreamV1SpanPayloadKind, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' +import { CallIntegrationTool } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' +import { CONTEXT_COMPACTION_DISPLAY_TITLE } from '@/lib/copilot/tools/tool-display' /** * The single deterministic model of one assistant turn, derived purely from the @@ -55,6 +58,13 @@ export interface ToolNode extends NodeBase { args?: Record streamingArgs?: string uiTitle?: string + /** + * Model-authored activity phrase for a gateway-resolved integration call + * (e.g. "Reading recent emails"). Captured when the authoritative resolved + * frame rebinds the node's name to the exact operation, because the resolved + * args no longer carry the gateway's `description` field. + */ + integrationDescription?: string /** Per-call `ui.hidden` flag — the node is tracked for side effects but not rendered. */ hidden?: boolean result?: { success: boolean; output?: unknown; error?: string } @@ -180,6 +190,26 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } +/** + * The integration gateway intentionally emits a second authoritative call frame + * under the SAME provider call id once Go resolves the exact server-owned + * operation (call_integration_tool -> e.g. gmail_read_v2). Rebind the node to + * that operation so the row brands from the real integration (name -> block + * registry) and keep only the model-authored `description` for presentation — + * the caller then replaces args with the resolved operation args, which no + * longer carry the gateway envelope fields. + */ +function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void { + if (node.name !== CallIntegrationTool.id) return + if (!toolName || toolName === CallIntegrationTool.id) return + const description = + asString(node.args?.description)?.trim() || + extractStreamingStringArgument(node.streamingArgs, 'description')?.trim() + if (description) node.integrationDescription = description + node.name = toolName + node.streamingArgs = undefined +} + /** * Reads a wire event payload as a generic record. The payload is a wide * discriminated union; the reducer accesses fields uniformly, so this narrows @@ -473,7 +503,13 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq, tsMs ) + rebindResolvedIntegrationCall(node, toolName) if (isRecord(payload.arguments)) node.args = payload.arguments + // Only the snapshot-replay path (contentBlocksToModel) carries this + // field — the live wire never does; it restores the rebound gateway + // description across a preserve-state rebuild. + const restoredDescription = asString(payload.integrationDescription) + if (restoredDescription) node.integrationDescription = restoredDescription // Tool-call titles are derived from the tool name (+args) at serialize // time; the stream only carries behavioral flags now. const ui = isRecord(payload.ui) ? payload.ui : undefined @@ -515,7 +551,28 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { breakLane(model, parentSpanId, tsMs) const existingId = model.agentBySpanId.get(resolvedSpanId) - if (existingId && model.nodes.has(existingId)) break + const existing = existingId ? model.nodes.get(existingId) : undefined + if (existing && existing.kind === 'agent') { + // The lane was pre-created by ensureSubagentLane from a content event + // that raced ahead of this start. That event's scope may omit agentId + // (contract-optional) while only this start carries payload.agent — + // an empty agentId makes the downstream parsers drop the whole lane. + // Reconcile instead of ignoring the start — and overwrite a NONEMPTY + // mismatched provisional owner too: a racing content event's + // scope.agentId can name the forwarding caller (e.g. superagent), + // while this start's payload.agent is the authoritative lane owner. + if (agentId && existing.agentId !== agentId) existing.agentId = agentId + if (!existing.triggerToolCallId && triggerToolCallId) { + existing.triggerToolCallId = triggerToolCallId + } + if (existing.parentSpanId === MAIN_SPAN && parentSpanId !== MAIN_SPAN) { + existing.parentSpanId = parentSpanId + } + if (existing.startedAtMs === undefined && tsMs !== undefined) { + existing.startedAtMs = tsMs + } + break + } const node: AgentNode = { kind: 'agent', id: resolvedSpanId, @@ -546,6 +603,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const payload = payloadRecord(envelope.payload) const kind = payload.kind if (kind === MothershipStreamV1RunKind.compaction_start) { + ensureSubagentLane(model, spanId, scope, seq, tsMs) const node = upsertToolNode( model, `compaction:${seq}`, @@ -554,18 +612,20 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq, tsMs ) - node.uiTitle = 'Compacting context...' + node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE } else if (kind === MothershipStreamV1RunKind.compaction_done) { + ensureSubagentLane(model, spanId, scope, seq, tsMs) let finalized = false for (let i = model.order.length - 1; i >= 0; i--) { const node = model.nodes.get(model.order[i]) if ( node?.kind === 'tool' && + node.spanId === spanId && node.name === 'context_compaction' && node.status === 'running' ) { node.status = 'success' - node.uiTitle = 'Compacted context' + node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE finalized = true break } @@ -580,7 +640,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve tsMs ) node.status = 'success' - node.uiTitle = 'Compacted context' + node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE } } break diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index e81ec0603f9..0dedc8c8bd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -314,6 +314,8 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.blockType === 'string' case 'skill': return typeof value.skillId === 'string' + case 'mcp': + return typeof value.serverId === 'string' default: return false } @@ -604,6 +606,7 @@ function toRawPersistedContentBlockBody(block: ContentBlock): Record targetChatId?: string shouldContinue?: () => boolean @@ -1328,8 +1334,14 @@ export function useChat( currentBlocks: streamingBlocksRef.current, }) - streamingContentRef.current = selection.content - streamingBlocksRef.current = selection.contentBlocks + // A reset replays from cursor 0 into a fresh model that never reads + // these refs — keep the previous snapshot visible (and stop-persistable) + // until the replay's terminal flush overwrites it, instead of collapsing + // the rendered message to empty. + if (selection.source === 'cache') { + streamingContentRef.current = selection.content + streamingBlocksRef.current = selection.contentBlocks + } lastCursorRef.current = selection.afterCursor if (selection.afterCursor === '0' && afterCursor !== '0') { @@ -1930,6 +1942,8 @@ export function useChat( expectedGen?: number, options?: { preserveExistingState?: boolean + resumeCursor?: string + deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean @@ -2038,6 +2052,17 @@ export function useChat( state.scheduledTextFlushFrame = null ops.flush() } + if (state.scheduledTextFlushTimer !== null) { + clearTimeout(state.scheduledTextFlushTimer) + state.scheduledTextFlushTimer = null + ops.flush() + } + // Batch-replay mode publishes exactly one snapshot, here at the end, + // so the rendered message goes stale-prefix -> full in a single step + // instead of collapsing and re-revealing through partial flushes. + if (options?.deferFlushes) { + ops.forceFlush() + } if (streamReaderRef.current === reader) { streamReaderRef.current = null } @@ -2233,6 +2258,8 @@ export function useChat( expectedGen, { preserveExistingState: preserveNextReplayState, + resumeCursor: latestCursor, + deferFlushes: true, suppressedWorkflowToolStartIds: suppressedSeedWorkflowToolStartIds, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), @@ -2292,6 +2319,7 @@ export function useChat( expectedGen, { preserveExistingState: preserveNextReplayState, + resumeCursor: latestCursor, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -2395,6 +2423,8 @@ export function useChat( gen, { preserveExistingState: replaySelection.preserveExistingState, + resumeCursor: replaySelection.afterCursor, + deferFlushes: true, suppressedWorkflowToolStartIds: getReplayCompletedWorkflowToolCallIds(batch.events), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), @@ -2765,6 +2795,15 @@ export function useChat( ...(typeof block.timestamp === 'number' ? { timestamp: block.timestamp } : {}), ...(typeof block.endedAt === 'number' ? { endedAt: block.endedAt } : {}), } + // Span identity must survive this serializer too (matching + // toRawPersistedContentBlock): a stop-persisted turn that loses + // spanId/parentSpanId permanently renders through the legacy flat + // parser — nested subagents hoist to the top level on every reload. + const spanIdentity = { + ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), + ...(block.spanId ? { spanId: block.spanId } : {}), + ...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}), + } if (block.type === 'tool_call' && block.toolCall) { const isCancelled = block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled' @@ -2782,7 +2821,7 @@ export function useChat( ...(display ? { display } : {}), calledBy: block.toolCall.calledBy, }, - ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), + ...spanIdentity, ...timing, } } @@ -2790,7 +2829,7 @@ export function useChat( type: block.type, content: block.content, ...(block.subagent ? { lane: 'subagent' } : {}), - ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), + ...spanIdentity, ...timing, } }) @@ -3041,6 +3080,7 @@ export function useChat( ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), + ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), })) const cachedUserMsg: PersistedMessage = { id: userMessageId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 20bc2513dab..fa85cc7cadf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -73,6 +73,8 @@ export interface ToolCallInfo { name: string status: ToolCallStatus displayTitle?: string + /** Model-authored activity phrase for a gateway-resolved integration call. */ + integrationDescription?: string params?: Record calledBy?: string result?: ToolCallResult @@ -136,6 +138,7 @@ export interface ChatMessageContext { chatId?: string blockType?: string skillId?: string + serverId?: string } export interface ChatMessage { @@ -157,6 +160,8 @@ export const SUBAGENT_LABELS: Record = { knowledge: 'Knowledge Agent', table: 'Table Agent', custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index d7ecd79e36e..2d7abb20569 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' import { blockTypeToIconMap, @@ -88,14 +89,17 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration }, [isSlackBot, blockOverlayVersion]) const hasServiceAccount = Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden - // Vendor-accurate connect label: token-paste providers use their own noun - // ("Add API key", "Add private app token"); only true service-account - // providers (Google, Atlassian) say "Add service account". - const tokenDescriptor = getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) + // Vendor-accurate connect label: token-paste and client-credential + // providers use their own noun ("Add API key", "Add server-to-server app"); + // only true service-account providers (Google, Atlassian) say + // "Add service account". + const nounDescriptor = + getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ?? + getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId) const serviceAccountConnectLabel = isSlackBot ? 'Set up a custom bot' - : tokenDescriptor - ? `Add ${tokenDescriptor.connectNoun}` + : nounDescriptor + ? `Add ${nounDescriptor.connectNoun}` : 'Add service account' const hasHandledConnectQueryRef = useRef(false) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx new file mode 100644 index 00000000000..1d7f000d371 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -0,0 +1,267 @@ +'use client' + +import { type ComponentType, useEffect, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + SecretInput, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { isApiClientError } from '@/lib/api/client/errors' +import type { + ClientCredentialAccountDescriptor, + ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + useCreateWorkspaceCredential, + useUpdateWorkspaceCredential, +} from '@/hooks/queries/credentials' + +const logger = createLogger('ClientCredentialAccountModal') + +const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment." + +/** + * Maps server `error.code` values from client-credential verification (a real + * token mint against the provider) to user-facing messages, personalized with + * the provider's field labels. + */ +function messageForClientCredentialError( + err: unknown, + descriptor: ClientCredentialAccountDescriptor +): string { + if (isApiClientError(err) && err.code) { + const fieldLabels = descriptor.fields.map((field) => field.label).join(', ') + switch (err.code) { + case 'invalid_credentials': + return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.` + case 'site_not_found': + return `We couldn't find a ${descriptor.serviceLabel} account at that host. Check the spelling of the host field and try again.` + case 'provider_unavailable': + return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.` + case 'duplicate_display_name': + return 'A credential with that name already exists in this workspace.' + default: + return FALLBACK_ERROR_MESSAGE + } + } + return FALLBACK_ERROR_MESSAGE +} + +function openDocs(url: string): void { + window.open(url, '_blank', 'noopener,noreferrer') +} + +interface ClientCredentialAccountModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + descriptor: ClientCredentialAccountDescriptor + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** When set, reconnect (rotate the secrets on) this credential in place. */ + credentialId?: string + initialDisplayName?: string + initialDescription?: string +} + +/** + * Generic connect modal for client-credentials service accounts (Zoom + * Server-to-Server OAuth, Box CCG). Renders the client id, client secret, and + * org-identifier fields declared by the provider's + * {@link ClientCredentialAccountDescriptor} and submits through the same + * create/update credential mutations as the other service-account modals. + * The server verifies the triple by minting a real access token; failures are + * mapped from the route's `error.code`. + */ +export function ClientCredentialAccountModal({ + open, + onOpenChange, + workspaceId, + descriptor, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, +}: ClientCredentialAccountModalProps) { + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [orgId, setOrgId] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + useEffect(() => { + if (open) return + setClientId('') + setClientSecret('') + setOrgId('') + setDisplayName(initialDisplayName ?? '') + setDescription(initialDescription ?? '') + setError(null) + }, [open, initialDisplayName, initialDescription]) + + const clientIdField = descriptor.fields.find((field) => field.id === 'clientId') + const clientSecretField = descriptor.fields.find((field) => field.id === 'clientSecret') + const orgIdField = descriptor.fields.find((field) => field.id === 'orgId') + + const trimmedClientId = clientId.trim() + const trimmedClientSecret = clientSecret.trim() + const trimmedOrgId = orgId.trim() + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = !trimmedClientId || !trimmedClientSecret || !trimmedOrgId || isPending + + const hintFor = ( + field: ClientCredentialAccountField | undefined, + value: string + ): string | undefined => { + if (!field?.hintPattern || !field.hintMessage || value.length === 0) return undefined + const normalized = field.hintNormalize ? field.hintNormalize(value) : value + return field.hintPattern.test(normalized) ? undefined : field.hintMessage + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled) return + try { + const secretFields = { + clientId: trimmedClientId, + clientSecret: trimmedClientSecret, + orgId: trimmedOrgId, + } + if (credentialId) { + await updateCredential.mutateAsync({ + credentialId, + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } else { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: descriptor.providerId, + ...secretFields, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } + onOpenChange(false) + } catch (err: unknown) { + setError(messageForClientCredentialError(err, descriptor)) + logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) + } + } + + return ( + + onOpenChange(false)}> + Add {serviceName} {descriptor.connectNoun} + + + {clientIdField && ( + { + setClientId(value) + if (error) setError(null) + }} + placeholder={clientIdField.placeholder} + autoComplete='off' + required + hint={hintFor(clientIdField, trimmedClientId)} + /> + )} + + {clientSecretField && ( + + { + setClientSecret(value) + if (error) setError(null) + }} + placeholder={clientSecretField.placeholder} + name={`${descriptor.providerId}_client_secret`} + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + + )} + + {orgIdField && ( + { + setOrgId(value) + if (error) setError(null) + }} + placeholder={orgIdField.placeholder} + autoComplete='off' + required + hint={hintFor(orgIdField, trimmedOrgId)} + /> + )} + + + + + + {error} + + onOpenChange(false)} + secondaryActions={[ + { + label: 'Setup guide', + onClick: () => openDocs(descriptor.docsUrl), + }, + ]} + primaryAction={{ + label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`, + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 6c8f6722601..83ae7ad48b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -14,6 +14,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isApiClientError } from '@/lib/api/client/errors' import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' +import { + type ClientCredentialAccountProviderId, + getClientCredentialAccountDescriptor, +} from '@/lib/credentials/client-credential-accounts/descriptors' import { getTokenServiceAccountDescriptor, type TokenServiceAccountProviderId, @@ -22,6 +26,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' +import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal' import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { @@ -38,6 +43,7 @@ export type ServiceAccountProviderId = | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID | TokenServiceAccountProviderId + | ClientCredentialAccountProviderId /** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' @@ -127,6 +133,22 @@ export function ConnectServiceAccountModal({ credentialDisplayName, credentialDescription, }: ConnectServiceAccountModalProps) { + const clientCredentialDescriptor = getClientCredentialAccountDescriptor(serviceAccountProviderId) + if (clientCredentialDescriptor) { + return ( + + ) + } const tokenDescriptor = getTokenServiceAccountDescriptor(serviceAccountProviderId) if (tokenDescriptor) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index c2bd9879894..b8f0399d751 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -157,7 +157,8 @@ describe('WorkspaceLayout host context', () => { expect.anything(), 'workspace-b', 'viewer-1', - HOST_CONTEXT + HOST_CONTEXT, + 'org-a' ) expect(mockBrandingProvider).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index f10df8f2883..a2cae048084 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner, @@ -44,12 +45,19 @@ export default async function WorkspaceLayout({ return } + const activeOrganizationId = getActiveOrganizationId(session) const [cookieStore, initialOrgSettings] = await Promise.all([ cookies(), hostContext.hostOrganizationId ? getOrgWhitelabelSettings(hostContext.hostOrganizationId) : Promise.resolve(null), - prefetchWorkspaceSidebar(queryClient, workspaceId, session.user.id, hostContext), + prefetchWorkspaceSidebar( + queryClient, + workspaceId, + session.user.id, + hostContext, + activeOrganizationId + ), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index f898833b891..205ad59a408 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -15,7 +15,7 @@ interface FileData { type: string key: string url: string - storageProvider?: 's3' | 'blob' | 'local' + storageProvider?: 's3' | 'blob' | 'gcs' | 'local' bucketName?: string } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index e88c30a17cd..629524f244e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -61,8 +61,7 @@ export function getBlockIconAndColor( if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor } } const normalized = normalizeToolId(toolName) - if (normalized === 'load_skill' || normalized === 'load_user_skill') - return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } const toolBlock = getBlockByToolName(normalized) if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } } diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 527905b9c22..e54318c55a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,9 +1,10 @@ import type { QueryClient } from '@tanstack/react-query' -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { listFoldersForWorkspace } from '@/lib/folders/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' import { @@ -14,6 +15,10 @@ import { import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' +import { + normalizeWorkspacesResponse, + WORKSPACE_LIST_STALE_TIME, +} from '@/hooks/queries/utils/workspace-list-query' import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' import { WORKSPACE_HOST_CONTEXT_STALE_TIME, @@ -37,22 +42,27 @@ export function prefetchWorkspaceHostContext( } /** - * Prefetches the sidebar's workflow, chat, folder, and workspace-permissions lists for - * a workspace and stores them under the same query keys + mappers the client hooks use, - * so the persistent sidebar paints populated on the first server render instead of - * flashing skeletons on a cold load (e.g. after the browser discards an idle tab). Calls - * the data layer directly — the same functions the API routes use — with no internal - * HTTP hop. + * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, and + * workspace lists for a workspace and stores them under the same query keys + + * mappers the client hooks use, so the persistent sidebar (including the + * workspace switcher header) paints populated on the first server render + * instead of flashing skeletons on a cold load (e.g. after the browser + * discards an idle tab). Calls the data layer directly — the same functions + * the API routes use — with no internal HTTP hop. * * The host context is the authorization proof for this server-render pass, so * permission prefetch can reuse its effective permission without repeating - * workspace and membership reads. + * workspace and membership reads. It also proves the viewer has at least one + * accessible workspace, which is why the workspace-list prefetch can safely + * skip the route's empty-list default-workspace creation path — and the + * route's orphaned-workflow repair, which still runs on client refetches. */ export async function prefetchWorkspaceSidebar( queryClient: QueryClient, workspaceId: string, userId: string, - hostContext: WorkspaceHostContext + hostContext: WorkspaceHostContext, + activeOrganizationId: string | null ): Promise { if (hostContext.workspace.id !== workspaceId) return await Promise.all([ @@ -80,6 +90,27 @@ export async function prefetchWorkspaceSidebar( }, staleTime: FOLDER_LIST_STALE_TIME, }), + queryClient.prefetchQuery({ + queryKey: workspaceKeys.list('active'), + queryFn: async () => { + const payload = await listWorkspacesForViewer({ + userId, + activeOrganizationId, + scope: 'active', + }) + // An empty list means GET /api/workspaces' default-workspace creation + // path must run — throw so prefetchQuery caches nothing and the client + // fetch reaches the route. + if (payload.workspaces.length === 0) { + throw new Error('Empty workspace list requires the route creation path') + } + // Parsing through the route contract's response schema strips the same + // server-only fields `requestJson` strips on the client, guaranteeing the + // cached shape is identical to a client fetch. + return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) + }, + staleTime: WORKSPACE_LIST_STALE_TIME, + }), queryClient.prefetchQuery({ queryKey: workspaceKeys.permissions(workspaceId), queryFn: () => diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 47000738717..5798db7b329 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -20,6 +20,7 @@ import { HunterIOIcon, IcypeasIcon, JinaAIIcon, + KimiIcon, LeadMagicIcon, LinkupIcon, MillionVerifierIcon, @@ -86,6 +87,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'xai-...', }, + { + id: 'kimi', + name: 'Kimi', + icon: KimiIcon, + description: 'LLM calls', + placeholder: 'sk-...', + }, { id: 'fireworks', name: 'Fireworks', @@ -298,6 +306,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'google', 'mistral', 'xai', + 'kimi', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index d4febb0cc77..816542027f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -81,7 +81,11 @@ export function useContextManagement({ message, initialContexts }: UseContextMan // `(^|\s)` boundary still matches the (regular) space or start that // precedes it, then the literal sentinel. const prefix = - c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' + c.kind === 'skill' || c.kind === 'mcp' + ? SKILL_CHIP_TRIGGER + : c.kind === 'slash_command' + ? '/' + : '@' const tokenPattern = new RegExp( `(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])` ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index 5a7d866ee64..cc5348690f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -61,7 +61,7 @@ export function useMentionTokens({ // Find matching context to determine if it's a slash command const matchingContext = selectedContexts.find((c) => c.label === label) const prefix = - matchingContext?.kind === 'skill' + matchingContext?.kind === 'skill' || matchingContext?.kind === 'mcp' ? SKILL_CHIP_TRIGGER : matchingContext?.kind === 'slash_command' ? '/' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 04dbf433d62..32aaf9aa159 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -45,7 +45,11 @@ export function extractContextTokens(contexts: ChatContext[]): string[] { .filter((c) => c.kind !== 'current_workflow' && c.label) .map((c) => { const prefix = - c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' + c.kind === 'skill' || c.kind === 'mcp' + ? SKILL_CHIP_TRIGGER + : c.kind === 'slash_command' + ? '/' + : '@' return `${prefix}${c.label}` }) } @@ -185,6 +189,7 @@ type LogsContext = Extract type IntegrationContext = Extract type SlashCommandContext = Extract type SkillContext = Extract +type McpContext = Extract /** * Checks if two contexts of the same kind are equal by their ID fields. @@ -244,6 +249,10 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as SkillContext return c.skillId === ctx.skillId } + case 'mcp': { + const ctx = context as McpContext + return c.serverId === ctx.serverId + } default: return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index 9bca6377595..2138261cfc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -17,6 +17,7 @@ import { } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' +import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' @@ -89,14 +90,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro useEffect(() => { if (open) { - const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim() - const isDefaultDescription = - !workflowMetadata?.description || - workflowMetadata.description === workflowMetadata.name || - normalizedDesc === 'new workflow' || - normalizedDesc === 'your first workflow - start building here!' - - const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || '' + const initialDescription = + getMeaningfulWorkflowDescription(workflowMetadata?.description, workflowMetadata?.name) ?? + '' setDescription(initialDescription) initialDescriptionRef.current = initialDescription @@ -181,7 +177,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro await updateWorkflowMutation.mutateAsync({ workspaceId, workflowId, - metadata: { description: description.trim() || 'New workflow' }, + metadata: { description: description.trim() }, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 14781118029..868bca830f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -207,6 +207,20 @@ export function Versions({
{versions.map((v) => { const isSelected = selectedVersion === v.version + const operationStatus = + !v.isActive && v.latestOperationStatus !== 'active' ? v.latestOperationStatus : null + const isOperationPending = + operationStatus === 'preparing' || operationStatus === 'activating' + /** Exactly one parenthetical per row; selection already highlights the row. */ + const rowLabel = v.isActive + ? 'live' + : isOperationPending + ? 'pending' + : operationStatus === 'failed' + ? 'failed' + : isSelected + ? 'selected' + : null return (
{editingVersion === v.version ? ( {v.name && {v.name}} - {v.isActive && ( - (live) - )} - {isSelected && ( - (selected) + {rowLabel && ( + ({rowLabel}) )} )} 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 68178c9f3c4..3af6d13baf0 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 @@ -17,11 +17,13 @@ import { ModalTabsList, ModalTabsTrigger, Tooltip, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' +import type { DeploymentOperationSummary } from '@/lib/api/contracts/deployments' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -110,7 +112,6 @@ export function DeployModal({ const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) const [deployError, setDeployError] = useState(null) - const [deployWarnings, setDeployWarnings] = useState([]) const [isFinalizingDeploy, setIsFinalizingDeploy] = useState(false) const [isActivatingVersion, setIsActivatingVersion] = useState(false) const [isChatFormValid, setIsChatFormValid] = useState(false) @@ -149,7 +150,7 @@ export function DeployModal({ data: deploymentInfoData, isLoading: isLoadingDeploymentInfo, refetch: refetchDeploymentInfo, - } = useDeploymentInfo(workflowId, { enabled: open && isDeployed }) + } = useDeploymentInfo(workflowId, { enabled: open }) const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, { enabled: open, @@ -170,30 +171,44 @@ export function DeployModal({ const activateVersionMutation = useActivateDeploymentVersion() const versions = versionsData?.versions ?? [] + const deploymentAttemptStatus = deploymentInfoData?.latestDeploymentAttempt?.status + const attemptErrorMessage = + deploymentInfoData?.latestDeploymentAttempt?.error?.message ?? + (deploymentAttemptStatus === 'failed' ? 'Deployment preparation failed' : null) const isWorkflowStillActive = (targetWorkflowId: string) => { return useWorkflowRegistry.getState().activeWorkflowId === targetWorkflowId } - const syncDraftAfterDeploy = async (): Promise => { - if (!workflowId) return null + const syncDraftAfterDeploy = async (): Promise => { + if (!workflowId) return try { - const syncedActiveWorkflow = await syncLocalDraftFromServer(workflowId) - if (!syncedActiveWorkflow && isWorkflowStillActive(workflowId)) { - return 'Deployment succeeded, but local sync is still catching up. Refresh if the status looks stale.' - } - return null + await syncLocalDraftFromServer(workflowId) } catch (error) { - if (!isWorkflowStillActive(workflowId)) return null + if (!isWorkflowStillActive(workflowId)) return logger.warn('Workflow deployed, but local draft sync failed', { workflowId, error: toError(error).message, }) - return 'Deployment succeeded, but local sync failed. Refresh if the status looks stale.' } } + /** + * Post-activation warnings (dead-lettered or still-queued side effects) + * arrive with an `active` attempt, so the Live badge gives no signal — + * surface them as a toast. Pending/failed attempts are excluded: the + * status badge already covers those. + */ + const toastPostActivationWarnings = ( + title: string, + result: { latestDeploymentAttempt?: { status: string } | null; warnings?: string[] } + ) => { + if (result.latestDeploymentAttempt?.status !== 'active') return + if (!result.warnings?.length) return + toast.warning(title, { description: result.warnings.join(' ') }) + } + useEffect(() => { deployActionIdRef.current += 1 setIsFinalizingDeploy(false) @@ -241,7 +256,6 @@ export function DeployModal({ if (open && workflowId) { setActiveTab('general') setDeployError(null) - setDeployWarnings([]) setChatSuccess(false) const currentOutputs = selectedStreamingOutputsRef.current @@ -297,7 +311,6 @@ export function DeployModal({ deployActionIdRef.current = actionId setIsFinalizingDeploy(true) setDeployError(null) - setDeployWarnings([]) try { if (!(await deployReadiness.waitUntilReady())) { @@ -309,9 +322,12 @@ export function DeployModal({ try { const result = await deployMutation.mutateAsync({ workflowId }) - const syncWarning = await syncDraftAfterDeploy() - if (!isWorkflowStillActive(workflowId) || deployActionIdRef.current !== actionId) return - setDeployWarnings([...(result.warnings || []), ...(syncWarning ? [syncWarning] : [])]) + if (result.latestDeploymentAttempt?.status === 'active') { + await syncDraftAfterDeploy() + } + if (isWorkflowStillActive(workflowId)) { + toastPostActivationWarnings('Workflow deployed', result) + } } finally { if (deployActionIdRef.current === actionId) { setIsFinalizingDeploy(false) @@ -337,18 +353,17 @@ export function DeployModal({ activateVersionInFlightRef.current = true setIsActivatingVersion(true) - setDeployWarnings([]) + setDeployError(null) try { const result = await activateVersionMutation.mutateAsync({ workflowId, version }) - if (!isWorkflowStillActive(workflowId)) return - if (result.warnings && result.warnings.length > 0) { - setDeployWarnings(result.warnings) + if (isWorkflowStillActive(workflowId)) { + toastPostActivationWarnings(`Promoted v${version} to live`, result) } } catch (error) { if (!isWorkflowStillActive(workflowId)) return logger.error('Error promoting version:', { error }) - throw error + setDeployError(toError(error).message || `Failed to promote v${version} to live`) } finally { activateVersionInFlightRef.current = false setIsActivatingVersion(false) @@ -363,20 +378,23 @@ export function DeployModal({ return } - setDeployWarnings([]) - try { const result = await undeployMutation.mutateAsync({ workflowId: targetWorkflowId }) if (!isWorkflowStillActive(targetWorkflowId)) return setUndeployTargetWorkflowId(null) - if (result.warnings && result.warnings.length > 0) { - setDeployWarnings(result.warnings) - return - } onOpenChange(false) + /** + * Partial cleanup warnings (e.g. external subscription teardown left to + * background retries) surface as a toast so closing the modal does not + * silently swallow them. + */ + if (result.warnings?.length) { + toast.warning('Workflow undeployed', { description: result.warnings.join(' ') }) + } } catch (error: unknown) { if (!isWorkflowStillActive(targetWorkflowId)) return logger.error('Error undeploying workflow:', { error }) + toast.error('Failed to undeploy workflow', { description: toError(error).message }) } } @@ -388,7 +406,6 @@ export function DeployModal({ deployActionIdRef.current = actionId setIsFinalizingDeploy(true) setDeployError(null) - setDeployWarnings([]) try { if (!(await deployReadiness.waitUntilReady())) { @@ -414,9 +431,12 @@ export function DeployModal({ try { const result = await deployMutation.mutateAsync({ workflowId }) - const syncWarning = await syncDraftAfterDeploy() - if (!isWorkflowStillActive(workflowId) || deployActionIdRef.current !== actionId) return - setDeployWarnings([...(result.warnings || []), ...(syncWarning ? [syncWarning] : [])]) + if (result.latestDeploymentAttempt?.status === 'active') { + await syncDraftAfterDeploy() + } + if (isWorkflowStillActive(workflowId)) { + toastPostActivationWarnings('Workflow redeployed', result) + } } finally { if (deployActionIdRef.current === actionId) { setIsFinalizingDeploy(false) @@ -442,7 +462,6 @@ export function DeployModal({ if (workflowId) releaseDeployAction(workflowId) setChatSubmitting(false) setDeployError(null) - setDeployWarnings([]) onOpenChange(false) } @@ -514,24 +533,11 @@ export function DeployModal({ Configure and manage workflow deployment settings including API, MCP, and chat options. - {(deployError || deployWarnings.length > 0) && ( -
- {deployError && ( - - {deployError} - - )} - {deployWarnings.map((warning) => ( - - {warning} - - ))} + {deployError && ( +
+ + {deployError} +
)} @@ -604,6 +610,8 @@ export function DeployModal({ isUndeploying={isUndeploying} deployReadiness={deployReadiness} isDeploymentSettling={isDeploymentSettling} + attemptStatus={deploymentAttemptStatus} + attemptErrorMessage={attemptErrorMessage} onDeploy={onDeploy} onRedeploy={handleRedeploy} onUndeploy={() => { @@ -746,15 +754,77 @@ export function DeployModal({ ) } +type DeploymentAttemptStatus = DeploymentOperationSummary['status'] + interface StatusBadgeProps { - isWarning: boolean + isDeployed: boolean + needsRedeployment: boolean + attemptStatus?: DeploymentAttemptStatus + attemptErrorMessage?: string | null } -function StatusBadge({ isWarning }: StatusBadgeProps) { - const label = isWarning ? 'Update deployment' : 'Live' +/** + * Lifecycle-aware deployment status badge. Pending attempts render amber + * (labelled Retrying once an attempt has recorded a transient error), failed + * attempts render red with the failure reason in a tooltip, and a settled + * live deployment falls back to the Live/Update states. + */ +function StatusBadge({ + isDeployed, + needsRedeployment, + attemptStatus, + attemptErrorMessage, +}: StatusBadgeProps) { + if (attemptStatus === 'preparing' || attemptStatus === 'activating') { + const isRetrying = Boolean(attemptErrorMessage) + return ( + + + + {isRetrying ? 'Retrying' : 'Pending'} + + + + {isRetrying &&

{attemptErrorMessage}

} +

+ {isRetrying + ? isDeployed + ? 'Retrying automatically. The current version stays live until cutover completes.' + : 'Retrying automatically. The workflow goes live once activation completes.' + : isDeployed + ? 'A new version is being prepared. The current version stays live until cutover completes.' + : 'Triggers and schedules are being registered. The workflow goes live once activation completes.'} +

+
+
+ ) + } + + if (attemptStatus === 'failed') { + return ( + + + + Failed + + + +

{attemptErrorMessage || 'Deployment preparation failed.'}

+

+ {isDeployed + ? 'The previously deployed version is still live.' + : 'The workflow remains undeployed.'} +

+
+
+ ) + } + + if (!isDeployed) return null + return ( - - {label} + + {needsRedeployment ? 'Update deployment' : 'Live'} ) } @@ -766,6 +836,8 @@ interface GeneralFooterProps { isUndeploying: boolean deployReadiness: DeployReadiness isDeploymentSettling: boolean + attemptStatus?: DeploymentAttemptStatus + attemptErrorMessage?: string | null onDeploy: () => Promise onRedeploy: () => Promise onUndeploy: () => void @@ -778,6 +850,8 @@ function GeneralFooter({ isUndeploying, deployReadiness, isDeploymentSettling, + attemptStatus, + attemptErrorMessage, onDeploy, onRedeploy, onUndeploy, @@ -788,12 +862,30 @@ function GeneralFooter({ deployReadiness.isBlocked && !deployReadiness.isSyncing && !isSubmitting && !isUndeploying ? deployReadiness.tooltip : null + const status = ( +
+ + {blockedMessage && ( +
+ {blockedMessage} +
+ )} +
+ ) const deployActionLoading = isSubmitting || isDeploymentSettling if (!isDeployed) { return ( -
{blockedMessage}
+ {status}
+
+ {expanded ? ( +
+ {/* Vertical guide dropping from the folder's checkbox, mirroring the sidebar tree. */} +
+
+ {folder.children.map((child) => ( + + ))} + {folder.workflows.map((workflow) => ( + + ))} +
+
+ ) : null} +
+ ) +} + +interface ExcludedWorkflowRowProps { + workflow: ExcludedWorkflowItem + level: number + excludedIds: ReadonlySet + onToggle: (workflowIds: string[], excluded: boolean) => void + disabled: boolean +} + +function ExcludedWorkflowRow({ + workflow, + level, + excludedIds, + onToggle, + disabled, +}: ExcludedWorkflowRowProps) { + const itemId = useId() + return ( + + ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index f4f18667a61..e7d7a7d6131 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -10,6 +10,7 @@ import { cn, FieldDivider, Label, + Tooltip, } from '@sim/emcn' import { ArrowRight } from 'lucide-react' import type { @@ -575,6 +576,23 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp const detailsError = controller.errorMessage ?? controller.diffErrorMessage const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0 + // Excluded workflows render greyed in the change list. Orient each name's tooltip + // to WHERE it is excluded (that's the only place it can be re-included): the sync's + // source is this workspace on push and the other workspace on pull. + const excludedRows = [ + ...(controller.direction === 'push' + ? controller.excludedSourceWorkflows + : controller.excludedTargetWorkflows + ).map((name) => ({ name, tooltip: 'Excluded from sync' })), + ...(controller.direction === 'push' + ? controller.excludedTargetWorkflows + : controller.excludedSourceWorkflows + ).map((name) => ({ + name, + tooltip: `Excluded from sync in "${controller.otherWorkspaceName}"`, + })), + ] + return (
@@ -607,33 +625,51 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp {/* Always shown once the diff loads so the user sees the section even with nothing deployed - an empty change list means the source has no deployed workflows (every - deployed workflow appears here, changed or not), so the muted state nudges a deploy. */} + deployed workflow appears here, changed or not), so the muted state nudges a deploy. + Sync-excluded workflows list greyed at the end, with a tooltip naming where the + exclusion lives - the sync will not touch them. */} {controller.hasDiff ? ( - {controller.workflowChanges.length > 0 ? ( -
- {controller.workflowChanges.map((change, index) => { - const renamed = change.currentName !== change.otherName - return ( -
- - {change.currentName} - - {renamed ? ( - <> - - - {change.otherName} + {controller.workflowChanges.length + excludedRows.length > 0 ? ( + +
+ {controller.workflowChanges.map((change, index) => { + const renamed = change.currentName !== change.otherName + return ( +
+ + {change.currentName} + + {renamed ? ( + <> + + + {change.otherName} + + + ) : null} +
+ ) + })} + {excludedRows.map(({ name, tooltip }, index) => ( +
+ + + + {name} - - ) : null} + + + {tooltip} + +
- ) - })} -
+ ))} +
+ ) : (
{controller.direction === 'push' diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index f84895e72b9..15f70c53e4d 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -152,6 +152,10 @@ export interface ForkSyncController { workflowChanges: ForkWorkflowChange[] /** Names of target workflows this sync archives, for the confirm modal. */ archivedWorkflowNames: string[] + /** Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. */ + excludedSourceWorkflows: string[] + /** Names of mapped TARGET workflows marked "Exclude from sync" - never replaced or archived. */ + excludedTargetWorkflows: string[] mcpReauthCount: number inlineSecretCount: number dirty: boolean @@ -796,6 +800,8 @@ export function useForkSync(params: { dependentClears, workflowChanges, archivedWorkflowNames, + excludedSourceWorkflows: diff.data?.excludedSourceWorkflows ?? [], + excludedTargetWorkflows: diff.data?.excludedTargetWorkflows ?? [], mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0, inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0, dirty, diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index f1ae007bd51..784a6cbe016 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -31,6 +31,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel' +import { ForkExcludedWorkflows } from '@/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows' import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' import { ARCHIVED_PREVIEW_LIMIT, @@ -313,11 +314,17 @@ export function Forks() { const runRollback = async () => { if (!undoableRun) return try { - await rollback.mutateAsync({ + const result = await rollback.mutateAsync({ workspaceId, body: { otherWorkspaceId: undoableRun.otherWorkspaceId }, }) - toast.success(`Undid sync from "${undoableRun.otherName}"`) + if (result.pendingActivations.length > 0) { + toast.warning(`Undid sync from "${undoableRun.otherName}"`, { + description: `${result.pendingActivations.length} restored deployment(s) are still activating. Undo stays available until they finish, in case a retry is needed.`, + }) + } else { + toast.success(`Undid sync from "${undoableRun.otherName}"`) + } setConfirmRollbackOpen(false) } catch (err) { toast.error(getErrorMessage(err, 'Undo failed')) @@ -367,7 +374,6 @@ export function Forks() { const parentVisible = parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower)) const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower)) - const hasRows = parent !== null || forks.length > 0 // The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it // persists) belongs to the child workspace configuring how it maps its parent's @@ -442,9 +448,7 @@ export function Forks() { {getErrorMessage(lineage.error, 'Failed to load forks')}

- ) : lineage.isLoading ? null : !hasRows ? ( - Click "Create fork" above to get started - ) : ( + ) : lineage.isLoading ? null : (
{parentVisible && parent !== null && ( @@ -506,6 +510,9 @@ export function Forks() { )} + + +
)} diff --git a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts index 3b806ca8b16..469cb4b8a13 100644 --- a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts +++ b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts @@ -12,14 +12,19 @@ import { type RollbackForkBody, rollbackForkContract, type UnlinkForkBody, + type UpdateForkExcludedWorkflowsBody, type UpdateForkMappingBody, unlinkForkContract, + updateForkExcludedWorkflowsContract, updateForkMappingContract, } from '@/lib/api/contracts/workspace-fork' import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces' import { backgroundWorkKeys } from '@/ee/workspace-forking/hooks/background-work' import { deploymentKeys } from '@/hooks/queries/deployments' +import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { workspaceKeys } from '@/hooks/queries/workspace' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' export type ForkDirection = 'push' | 'pull' @@ -201,3 +206,43 @@ export function useRollbackFork() { }, }) } + +/** + * Toggle "Exclude from sync" for a batch of workflows (one request per folder or + * row click in the Excluded workflows tree). Optimistically flips the flag in the + * workspace's cached workflow list so the tree responds instantly, then reconciles. + */ +export function useUpdateForkExcludedWorkflows() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars: { workspaceId: string; body: UpdateForkExcludedWorkflowsBody }) => + requestJson(updateForkExcludedWorkflowsContract, { + params: { id: vars.workspaceId }, + body: vars.body, + }), + onMutate: async (vars) => { + const listKey = workflowKeys.list(vars.workspaceId, 'active') + await queryClient.cancelQueries({ queryKey: listKey }) + const snapshot = queryClient.getQueryData(listKey) + const toggledIds = new Set(vars.body.workflowIds) + queryClient.setQueryData(listKey, (old) => + (old ?? []).map((w) => + toggledIds.has(w.id) ? { ...w, forkSyncExcluded: vars.body.forkSyncExcluded } : w + ) + ) + return { snapshot } + }, + onError: (_error, vars, context) => { + if (context?.snapshot) { + queryClient.setQueryData(workflowKeys.list(vars.workspaceId, 'active'), context.snapshot) + } + }, + onSettled: (_data, _error, vars) => { + // Exclusion changes what a sync or a new fork copies: refresh every edge's diff + // preview and the fork modal's deployed-workflow count with the list itself. + queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) + queryClient.invalidateQueries({ queryKey: forkKeys.resources(vars.workspaceId) }) + return invalidateWorkflowLists(queryClient, vars.workspaceId) + }, + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts index 9c7ab55c0dc..8bfc338bf20 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts @@ -37,7 +37,10 @@ export interface DeployedWorkflowSummary { * left by an inconsistent state) has nothing to copy, so excluding it here keeps the * diff/plan counts aligned with what apply actually writes instead of over-reporting * then silently skipping it. Correlated `exists` (not a join) so a workflow is never - * double-listed if more than one active version row ever exists. + * double-listed if more than one active version row ever exists. Workflows marked + * `forkSyncExcluded` never participate as a source: this one predicate keeps them + * out of the diff preview, promote (both directions), fork creation, and the + * mapping-view reference scan. */ export async function listDeployedWorkflows( executor: DbOrTx, @@ -57,6 +60,41 @@ export async function listDeployedWorkflows( and( eq(workflow.workspaceId, workspaceId), eq(workflow.isDeployed, true), + eq(workflow.forkSyncExcluded, false), + isNull(workflow.archivedAt), + exists( + db + .select({ one: sql`1` }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + ) + ) + ) +} + +/** + * The complement of {@link listDeployedWorkflows}'s exclusion predicate: deployed, + * non-archived workflows the workspace admin marked "Exclude from sync". Only the + * diff preview reads this, to show which workflows a sync deliberately skips; + * the promote itself never touches them. + */ +export async function listForkExcludedDeployedWorkflows( + executor: DbOrTx, + workspaceId: string +): Promise> { + return executor + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where( + and( + eq(workflow.workspaceId, workspaceId), + eq(workflow.isDeployed, true), + eq(workflow.forkSyncExcluded, true), isNull(workflow.archivedAt), exists( db diff --git a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts b/apps/sim/ee/workspace-forking/lib/mapping/resources.ts index 2d04e0b96aa..1df83dc9bcb 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/resources.ts @@ -436,11 +436,13 @@ export async function listForkCopyableResources( .from(workflow) // Match listDeployedWorkflows: a workflow only counts as copyable when it has an // actually-active deployment version, not just the isDeployed flag, so the fork - // modal's preflight count never over-reports "ghost" deployed workflows. + // modal's preflight count never over-reports "ghost" deployed workflows. Sync-excluded + // workflows are likewise omitted so the count matches what createFork actually copies. .where( and( eq(workflow.workspaceId, workspaceId), eq(workflow.isDeployed, true), + eq(workflow.forkSyncExcluded, false), isNull(workflow.archivedAt), exists( executor diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts index ab5b51948a5..af746c3c9ca 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts @@ -2,13 +2,16 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge' import type { ForkCopyableLabel, ForkCopyableSourceResource, } from '@/ee/workspace-forking/lib/mapping/resources' import { assembleForkCopyableUnmapped, + buildForkPromotePlanItems, buildPromoteWorkflowIdMap, + collectForkArchivedTargets, collectForkCopyableIdsByKind, collectForkUnreferencedCopyables, } from '@/ee/workspace-forking/lib/promote/promote-plan' @@ -96,6 +99,196 @@ describe('buildPromoteWorkflowIdMap', () => { }) expect(map.get('s')).toBe('t-new') }) + + it('still seeds a sync-excluded mapped pair (sibling references keep resolving)', () => { + // An excluded workflow never becomes an item, but its identity pair survives + // (source exists + target active), so a synced sibling that references it + // repoints to the existing target instead of clearing. + const map = buildPromoteWorkflowIdMap({ + identityMap: new Map([['excluded-src', 'excluded-tgt']]), + existingSourceIds: new Set(['excluded-src']), + targetActiveIds: new Set(['excluded-tgt']), + items: [{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' }], + }) + expect(map.get('excluded-src')).toBe('excluded-tgt') + }) +}) + +const deployed = (id: string, name = id): DeployedWorkflowSummary => ({ + id, + name, + description: null, + folderId: null, + sortOrder: 0, + isPublicApi: false, +}) + +/** + * `buildForkPromotePlanItems` decides which deployed source workflows a promote + * writes and how (replace vs create), and implements the target side of + * "Exclude from sync": an excluded mapped target must never be replaced. These + * cases lock in that a skipped target is reported (for the diff preview) and + * never written. + */ +describe('buildForkPromotePlanItems', () => { + it('classifies a mapped-active target as replace (carrying its name) and an unmapped source as create', () => { + const { items, excludedTargets } = buildForkPromotePlanItems({ + deployedSourceWorkflows: [deployed('a-src', 'Alpha'), deployed('b-src', 'Beta')], + sourceStateIds: new Set(['a-src', 'b-src']), + identityMap: new Map([['a-src', 'a-tgt']]), + targetActiveIds: new Set(['a-tgt']), + targetNameById: new Map([['a-tgt', 'Alpha (prod)']]), + excludedTargetIds: new Set(), + }) + expect(excludedTargets).toEqual([]) + expect(items).toHaveLength(2) + expect(items[0]).toMatchObject({ + sourceWorkflowId: 'a-src', + targetWorkflowId: 'a-tgt', + targetName: 'Alpha (prod)', + mode: 'replace', + }) + expect(items[1]).toMatchObject({ sourceWorkflowId: 'b-src', targetName: null, mode: 'create' }) + expect(items[1].targetWorkflowId).not.toBe('b-src') + }) + + it('skips a source whose state failed to load', () => { + const { items } = buildForkPromotePlanItems({ + deployedSourceWorkflows: [deployed('a-src')], + sourceStateIds: new Set(), + identityMap: new Map(), + targetActiveIds: new Set(), + targetNameById: new Map(), + excludedTargetIds: new Set(), + }) + expect(items).toEqual([]) + }) + + it('never replaces a sync-excluded mapped target and reports it for the preview', () => { + const { items, excludedTargets } = buildForkPromotePlanItems({ + deployedSourceWorkflows: [deployed('a-src', 'Alpha'), deployed('b-src', 'Beta')], + sourceStateIds: new Set(['a-src', 'b-src']), + identityMap: new Map([ + ['a-src', 'a-tgt'], + ['b-src', 'b-tgt'], + ]), + targetActiveIds: new Set(['a-tgt', 'b-tgt']), + targetNameById: new Map([ + ['a-tgt', 'Alpha (prod)'], + ['b-tgt', 'Beta (prod)'], + ]), + excludedTargetIds: new Set(['b-tgt']), + }) + expect(items.map((item) => item.sourceWorkflowId)).toEqual(['a-src']) + expect(excludedTargets).toEqual([{ id: 'b-tgt', name: 'Beta (prod)' }]) + }) + + it('treats an excluded-but-archived mapped target as create (recreated fresh, like any dead target)', () => { + // Exclusion protects a LIVE target. Once the target is archived the identity + // match already fails, so the source recreates a fresh (non-excluded) copy - + // same as the pre-existing dead-target behavior. + const { items, excludedTargets } = buildForkPromotePlanItems({ + deployedSourceWorkflows: [deployed('a-src')], + sourceStateIds: new Set(['a-src']), + identityMap: new Map([['a-src', 'a-tgt']]), + targetActiveIds: new Set(), + targetNameById: new Map(), + excludedTargetIds: new Set(['a-tgt']), + }) + expect(excludedTargets).toEqual([]) + expect(items).toHaveLength(1) + expect(items[0].mode).toBe('create') + }) +}) + +/** + * `collectForkArchivedTargets` removes previously-mapped targets whose source was + * deleted. These cases lock in the two protections: a source that still EXISTS + * (even excluded/undeployed) never archives its counterpart, and a sync-excluded + * target is never archived even when its source is gone. + */ +describe('collectForkArchivedTargets', () => { + const workflowRow = (parentResourceId: string, childResourceId: string | null) => ({ + resourceType: 'workflow', + parentResourceId, + childResourceId, + }) + + it('archives a mapped active target whose source was deleted', () => { + const { archivedTargetIds, archivedTargets, excludedTargets } = collectForkArchivedTargets({ + mappingRows: [workflowRow('gone-src', 'gone-tgt')], + sourceIsParent: true, + existingSourceIds: new Set(), + writtenTargetIds: new Set(), + targetActiveIds: new Set(['gone-tgt']), + targetNameById: new Map([['gone-tgt', 'Gone']]), + excludedTargetIds: new Set(), + }) + expect(archivedTargetIds).toEqual(['gone-tgt']) + expect(archivedTargets).toEqual([{ id: 'gone-tgt', name: 'Gone' }]) + expect(excludedTargets).toEqual([]) + }) + + it('keeps a target whose source still exists (a sync-excluded source never archives its counterpart)', () => { + // The caller builds existingSourceIds from ALL non-archived source workflows, + // independent of the deployed/excluded source set - so an excluded source + // stays "existing" and its previously-synced counterpart is left untouched. + const { archivedTargetIds } = collectForkArchivedTargets({ + mappingRows: [workflowRow('excluded-src', 'mapped-tgt')], + sourceIsParent: true, + existingSourceIds: new Set(['excluded-src']), + writtenTargetIds: new Set(), + targetActiveIds: new Set(['mapped-tgt']), + targetNameById: new Map([['mapped-tgt', 'Mapped']]), + excludedTargetIds: new Set(), + }) + expect(archivedTargetIds).toEqual([]) + }) + + it('never archives a sync-excluded target, reporting it for the preview instead', () => { + const { archivedTargetIds, excludedTargets } = collectForkArchivedTargets({ + mappingRows: [workflowRow('gone-src', 'protected-tgt')], + sourceIsParent: true, + existingSourceIds: new Set(), + writtenTargetIds: new Set(), + targetActiveIds: new Set(['protected-tgt']), + targetNameById: new Map([['protected-tgt', 'Protected']]), + excludedTargetIds: new Set(['protected-tgt']), + }) + expect(archivedTargetIds).toEqual([]) + expect(excludedTargets).toEqual([{ id: 'protected-tgt', name: 'Protected' }]) + }) + + it('skips written targets, inactive targets, unfilled mappings, and non-workflow rows', () => { + const { archivedTargetIds } = collectForkArchivedTargets({ + mappingRows: [ + workflowRow('gone-1', 'written-tgt'), + workflowRow('gone-2', 'archived-tgt'), + workflowRow('gone-3', null), + { resourceType: 'table', parentResourceId: 'gone-4', childResourceId: 'tbl-tgt' }, + ], + sourceIsParent: true, + existingSourceIds: new Set(), + writtenTargetIds: new Set(['written-tgt']), + targetActiveIds: new Set(['written-tgt']), + targetNameById: new Map(), + excludedTargetIds: new Set(), + }) + expect(archivedTargetIds).toEqual([]) + }) + + it('orients source/target by direction (pull: child is the source side)', () => { + const { archivedTargetIds } = collectForkArchivedTargets({ + mappingRows: [workflowRow('parent-tgt', 'child-src')], + sourceIsParent: false, + existingSourceIds: new Set(), + writtenTargetIds: new Set(), + targetActiveIds: new Set(['parent-tgt']), + targetNameById: new Map([['parent-tgt', 'Parent']]), + excludedTargetIds: new Set(), + }) + expect(archivedTargetIds).toEqual(['parent-tgt']) + }) }) describe('collectForkCopyableIdsByKind', () => { diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts index 5f3a773a1b4..79932823041 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts @@ -56,6 +56,11 @@ export interface ForkPromotePlan { archivedTargetIds: string[] /** Same as `archivedTargetIds`, with the target workflow name for the preview. */ archivedTargets: Array<{ id: string; name: string }> + /** + * Sync-excluded target workflows this promote would have replaced or archived - + * left untouched (the target side of "Exclude from sync"), reported for the preview. + */ + excludedTargets: Array<{ id: string; name: string }> references: ForkReference[] unmappedRequired: ForkReference[] @@ -128,6 +133,121 @@ export function buildPromoteWorkflowIdMap(params: { return workflowIdMap } +/** + * Classify each deployed source workflow into a plan item: `replace` when its + * identity-mapped target is still active, `create` otherwise. A source whose state + * failed to load is skipped, and a source whose mapped target is sync-excluded is + * reported in `excludedTargets` instead of written - the target side of the + * "Exclude from sync" contract. Pure - split from the DB reads so it is unit-testable. + */ +export function buildForkPromotePlanItems(params: { + deployedSourceWorkflows: DeployedWorkflowSummary[] + sourceStateIds: ReadonlySet + identityMap: Map + targetActiveIds: ReadonlySet + targetNameById: ReadonlyMap + excludedTargetIds: ReadonlySet +}): { + items: ForkPromotePlanItem[] + excludedTargets: Array<{ id: string; name: string }> +} { + const { + deployedSourceWorkflows, + sourceStateIds, + identityMap, + targetActiveIds, + targetNameById, + excludedTargetIds, + } = params + const items: ForkPromotePlanItem[] = [] + const excludedTargets: Array<{ id: string; name: string }> = [] + for (const source of deployedSourceWorkflows) { + if (!sourceStateIds.has(source.id)) continue + + const mappedTargetId = identityMap.get(source.id) + const activeTargetId = + mappedTargetId && targetActiveIds.has(mappedTargetId) ? mappedTargetId : null + if (activeTargetId && excludedTargetIds.has(activeTargetId)) { + excludedTargets.push({ + id: activeTargetId, + name: targetNameById.get(activeTargetId) ?? source.name, + }) + continue + } + items.push({ + sourceWorkflowId: source.id, + targetWorkflowId: activeTargetId ?? generateId(), + targetName: activeTargetId ? (targetNameById.get(activeTargetId) ?? null) : null, + mode: activeTargetId ? 'replace' : 'create', + sourceMeta: { + name: source.name, + description: source.description, + folderId: source.folderId, + sortOrder: source.sortOrder, + isPublicApi: source.isPublicApi, + }, + }) + } + return { items, excludedTargets } +} + +/** + * Previously-mapped, still-active targets to archive: their source was DELETED (not + * merely undeployed) and nothing in this promote writes them. A sync-excluded target + * is never archived - it lands in `excludedTargets` for the preview instead. Pure - + * split from the DB reads so it is unit-testable. + */ +export function collectForkArchivedTargets(params: { + mappingRows: Array<{ + resourceType: string + parentResourceId: string + childResourceId: string | null + }> + sourceIsParent: boolean + existingSourceIds: ReadonlySet + writtenTargetIds: ReadonlySet + targetActiveIds: ReadonlySet + targetNameById: ReadonlyMap + excludedTargetIds: ReadonlySet +}): { + archivedTargetIds: string[] + archivedTargets: Array<{ id: string; name: string }> + excludedTargets: Array<{ id: string; name: string }> +} { + const { + mappingRows, + sourceIsParent, + existingSourceIds, + writtenTargetIds, + targetActiveIds, + targetNameById, + excludedTargetIds, + } = params + const archivedTargetIds: string[] = [] + const excludedTargets: Array<{ id: string; name: string }> = [] + for (const row of mappingRows) { + if (row.resourceType !== 'workflow' || row.childResourceId == null) continue + const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId + const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId + if (existingSourceIds.has(mappedSourceId)) continue + if (writtenTargetIds.has(mappedTargetId)) continue + if (!targetActiveIds.has(mappedTargetId)) continue + if (excludedTargetIds.has(mappedTargetId)) { + excludedTargets.push({ + id: mappedTargetId, + name: targetNameById.get(mappedTargetId) ?? mappedTargetId, + }) + continue + } + archivedTargetIds.push(mappedTargetId) + } + const archivedTargets = archivedTargetIds.map((id) => ({ + id, + name: targetNameById.get(id) ?? id, + })) + return { archivedTargetIds, archivedTargets, excludedTargets } +} + /** * Collect the source ids of referenced-but-unmapped copyable resources, grouped by kind - the input * to the source-label lookup that builds {@link ForkPromotePlan.copyableUnmapped}. Pure. @@ -200,8 +320,9 @@ export function collectForkUnreferencedCopyables( * deployed state. Targets matched by the persisted workflow identity map are * replaced; unmatched deployed sources create new targets. A target is archived * only when it was previously mapped and its source is no longer deployed - - * target-native workflows are never touched. Shared by the diff preview and the - * promote orchestrator. + * target-native workflows are never touched. Sync-excluded targets are never + * replaced or archived either; they surface on `excludedTargets` for the preview. + * Shared by the diff preview and the promote orchestrator. */ export async function computeForkPromotePlan(params: { executor: DbOrTx @@ -269,7 +390,11 @@ export async function computeForkPromotePlan(params: { const [targetWorkflows, sourceWorkflowRows] = await Promise.all([ executor - .select({ id: workflow.id, name: workflow.name }) + .select({ + id: workflow.id, + name: workflow.name, + forkSyncExcluded: workflow.forkSyncExcluded, + }) .from(workflow) .where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))), executor @@ -280,37 +405,33 @@ export async function computeForkPromotePlan(params: { const targetActiveIds = new Set(targetWorkflows.map((w) => w.id)) const targetNameById = new Map(targetWorkflows.map((w) => [w.id, w.name])) + const excludedTargetIds = new Set( + targetWorkflows.filter((w) => w.forkSyncExcluded).map((w) => w.id) + ) // Every source workflow that still EXISTS (deployed or not). A mapped target is // archived only when its source was DELETED - not merely undeployed. A fresh fork // leaves the child's workflows undeployed, so pushing back must not archive the // parent's originals; undeployed sources are simply skipped (target left as-is). + // Sync-excluded sources are filtered out of `deployedSourceWorkflows` but still + // counted here, so excluding a workflow never archives its synced counterpart. const existingSourceIds = new Set(sourceWorkflowRows.map((w) => w.id)) - // Build the items and scan references in one pass from the pre-read source states - // (loaded before the caller's transaction; see loadSourceDeployedStates). - const items: ForkPromotePlanItem[] = [] + const { items, excludedTargets: replaceProtectedTargets } = buildForkPromotePlanItems({ + deployedSourceWorkflows, + sourceStateIds: new Set(sourceStates.keys()), + identityMap, + targetActiveIds, + targetNameById, + excludedTargetIds, + }) + + // Scan references from the pre-read source states (loaded before the caller's + // transaction; see loadSourceDeployedStates). Skipped sources contribute none, so + // an excluded workflow's dangling references never gate someone else's sync. const referenceByKey = new Map() - for (const source of deployedSourceWorkflows) { - const sourceState = sourceStates.get(source.id) + for (const item of items) { + const sourceState = sourceStates.get(item.sourceWorkflowId) if (!sourceState) continue - - const mappedTargetId = identityMap.get(source.id) - const isReplace = Boolean(mappedTargetId && targetActiveIds.has(mappedTargetId)) - const targetWorkflowId = isReplace ? (mappedTargetId as string) : generateId() - items.push({ - sourceWorkflowId: source.id, - targetWorkflowId, - targetName: isReplace ? (targetNameById.get(targetWorkflowId) ?? null) : null, - mode: isReplace ? 'replace' : 'create', - sourceMeta: { - name: source.name, - description: source.description, - folderId: source.folderId, - sortOrder: source.sortOrder, - isPublicApi: source.isPublicApi, - }, - }) - for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), resolver) .references) { referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference) @@ -324,20 +445,27 @@ export async function computeForkPromotePlan(params: { items, }) - const writtenTargetIds = new Set(items.map((item) => item.targetWorkflowId)) - const archivedTargetIds: string[] = [] - for (const row of mappingRows) { - if (row.resourceType !== 'workflow' || row.childResourceId == null) continue - const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId - const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId - if (existingSourceIds.has(mappedSourceId)) continue - if (writtenTargetIds.has(mappedTargetId)) continue - if (targetActiveIds.has(mappedTargetId)) archivedTargetIds.push(mappedTargetId) + const { + archivedTargetIds, + archivedTargets, + excludedTargets: archiveProtectedTargets, + } = collectForkArchivedTargets({ + mappingRows, + sourceIsParent, + existingSourceIds, + writtenTargetIds: new Set(items.map((item) => item.targetWorkflowId)), + targetActiveIds, + targetNameById, + excludedTargetIds, + }) + + // Replace-protection (source exists) and archive-protection (source deleted) are + // disjoint per pair; the map is cheap insurance against duplicate mapping rows. + const excludedTargetsById = new Map() + for (const target of [...replaceProtectedTargets, ...archiveProtectedTargets]) { + excludedTargetsById.set(target.id, target) } - const archivedTargets = archivedTargetIds.map((id) => ({ - id, - name: targetNameById.get(id) ?? id, - })) + const excludedTargets = Array.from(excludedTargetsById.values()) const cascade = await detectForkCascadeReferences({ executor, @@ -386,6 +514,7 @@ export async function computeForkPromotePlan(params: { workflowIdMap, archivedTargetIds, archivedTargets, + excludedTargets, references: allReferences, unmappedRequired, unmappedOptional, diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts index a3a63cacd05..fd5f5035892 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts @@ -178,6 +178,7 @@ function makePlan(overrides: Partial = {}): ForkPromotePlan { workflowIdMap: new Map(), archivedTargetIds: [], archivedTargets: [], + excludedTargets: [], references: [], unmappedRequired: [], unmappedOptional: [], diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 319db9b384d..5de3872f0f4 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -421,6 +421,17 @@ export async function promoteFork(params: PromoteForkParams): Promise 0) { + logger.info( + `[${requestId}] Promote leaving ${plan.excludedTargets.length} sync-excluded target workflow(s) untouched`, + { + sourceWorkspaceId, + targetWorkspaceId, + excludedTargets: plan.excludedTargets.map((target) => target.name), + } + ) + } + const now = new Date() // Copy the selected unmapped resources (referenced and unreferenced) into the target BEFORE diff --git a/apps/sim/ee/workspace-forking/lib/promote/reactivate-in-tx.ts b/apps/sim/ee/workspace-forking/lib/promote/reactivate-in-tx.ts index f5b129401d1..143ed4f6c2e 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/reactivate-in-tx.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/reactivate-in-tx.ts @@ -1,7 +1,12 @@ import { workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' import { and, eq } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox' +import { + DEPLOYMENT_READINESS_COMPONENTS, + enqueueWorkflowDeploymentPreparation, +} from '@/lib/workflows/deployment-outbox' +import { prepareWorkflowVersionActivation } from '@/lib/workflows/persistence/deployment-operations' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -16,24 +21,25 @@ interface ReactivateDeployedVersionParams { export interface ReactivateDeployedVersionResult { deploymentVersionId: string /** - * Outbox event id enqueued inside the transaction. Process it AFTER the tx commits - * (or rely on the outbox cron/reaper if the process dies first). + * Deployment operation admitted (or reused) for this reactivation. The + * caller checks it post-commit to learn whether cutover completed. */ - outboxEventId: string + operationId: string + /** + * Newly enqueued event. Reused idempotent operations already own a durable event. + */ + outboxEventId?: string } /** - * Reactivate a prior deployment version AND restore the workflow's draft to it using - * ONLY DB writes against the provided transaction, enqueuing the deployment - * side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing - * AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion} - * and `performRevertToVersion` so a fork rollback can run atomically under its fork - * advisory lock - the heavy side-effects never run inside the locked tx. + * Prepare a prior deployment version for v2 activation and restore the workflow's + * draft to it using only DB writes against the provided transaction. * * Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo * and must not be blocked by a workflow/folder lock (that check is also not tx-safe). - * Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state - * on retry. + * Idempotent: preparing the activation and overwriting the draft yield the same + * prepared operation and draft on retry; the cutover itself happens asynchronously + * through the deployment outbox. * * Returns null when the target version row no longer exists, so the caller can mark the * workflow skipped rather than failing the whole rollback. @@ -80,25 +86,6 @@ export async function reactivateDeployedVersionInTx( ) } - // Activate the target version (deactivate every other), mark the workflow deployed. - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - await tx - .update(workflowDeploymentVersion) - .set({ isActive: true }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) - await tx - .update(workflow) - .set({ isDeployed: true, deployedAt: now }) - .where(eq(workflow.id, workflowId)) - // Restore the draft to the deployed version's state. const hasVariables = Object.hasOwn(deployedState, 'variables') const restoredState: WorkflowState = { @@ -126,13 +113,34 @@ export async function reactivateDeployedVersionInTx( }) .where(eq(workflow.id, workflowId)) - const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, { + let outboxEventId: string | undefined + const prepared = await prepareWorkflowVersionActivation({ workflowId, deploymentVersionId: versionRow.id, - userId, - requestId, - forceRecreateSubscriptions: true, + actorId: userId, + requestHash: sha256Hex(JSON.stringify({ action: 'activate', workflowId, version, userId })), + idempotencyKey: `${requestId}:${workflowId}:reactivate:${version}`, + readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, + tx, + onPrepareTransaction: async (innerTx, operation) => { + if (!operation.deploymentVersionId || operation.version === null) { + throw new Error('Prepared rollback activation is missing its target version') + } + outboxEventId = await enqueueWorkflowDeploymentPreparation(innerTx, { + protocolVersion: operation.protocolVersion, + operationId: operation.id, + generation: operation.generation, + workflowId: operation.workflowId, + deploymentVersionId: operation.deploymentVersionId, + version: operation.version, + userId, + requestId, + checkpoints: {}, + }) + }, }) - - return { deploymentVersionId: versionRow.id, outboxEventId } + if (!prepared.success) { + throw new Error(prepared.error) + } + return { deploymentVersionId: versionRow.id, operationId: prepared.operation.id, outboxEventId } } diff --git a/apps/sim/ee/workspace-forking/lib/promote/rollback.test.ts b/apps/sim/ee/workspace-forking/lib/promote/rollback.test.ts index ed0e2b1c11a..340280ec150 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/rollback.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/rollback.test.ts @@ -15,6 +15,7 @@ const { mockEnqueueUndeploy, mockProcessOutbox, mockNotify, + mockGetDeploymentStatus, } = vi.hoisted(() => ({ mockResolveForkEdge: vi.fn(), mockAcquireTargetLock: vi.fn(), @@ -27,6 +28,7 @@ const { mockEnqueueUndeploy: vi.fn(), mockProcessOutbox: vi.fn(), mockNotify: vi.fn(), + mockGetDeploymentStatus: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ @@ -49,6 +51,10 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ undeployWorkflow: mockUndeploy, })) +vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ + getWorkflowDeploymentStatus: mockGetDeploymentStatus, +})) + vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ deleteWorkflowIdentityByIds: mockDeleteIdentity, })) @@ -101,7 +107,15 @@ describe('rollbackFork', () => { beforeEach(() => { vi.clearAllMocks() mockResolveForkEdge.mockResolvedValue(EDGE) - mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' }) + mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({ + deploymentVersionId: `dv-${workflowId}`, + operationId: `op-${workflowId}`, + outboxEventId: `evt-${workflowId}`, + })) + mockGetDeploymentStatus.mockImplementation(async (workflowId: string) => ({ + activeDeployment: null, + latestOperation: { id: `op-${workflowId}`, status: 'active' }, + })) mockUndeploy.mockResolvedValue({ success: true }) mockProcessOutbox.mockResolvedValue('completed') setTx([]) @@ -119,10 +133,6 @@ describe('rollbackFork', () => { }, }) mockGetLatestRun.mockResolvedValue(run) - mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({ - deploymentVersionId: `dv-${workflowId}`, - outboxEventId: `evt-${workflowId}`, - })) const result = await rollbackFork({ targetWorkspaceId: 'target-ws', @@ -136,6 +146,7 @@ describe('rollbackFork', () => { unarchived: 0, skipped: 0, skippedIds: [], + pendingActivations: [], }) // Deterministic (sorted) order: wf-a before wf-b. expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b']) @@ -151,7 +162,6 @@ describe('rollbackFork', () => { snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] }, }) mockGetLatestRun.mockResolvedValue(run) - mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' }) const result = await rollbackFork({ targetWorkspaceId: 'target-ws', @@ -166,7 +176,7 @@ describe('rollbackFork', () => { expect(mockReactivate).toHaveBeenCalledWith( expect.objectContaining({ workflowId: 'wf-x', version: 2 }) ) - expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x') + expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-x') expect(mockNotify).toHaveBeenCalledWith('wf-x') }) @@ -205,7 +215,9 @@ describe('rollbackFork', () => { }) mockGetLatestRun.mockResolvedValue(run) mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => - workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' } + workflowId === 'wf-b' + ? null + : { deploymentVersionId: 'dv', operationId: 'op-wf-a', outboxEventId: 'evt-wf-a' } ) const result = await rollbackFork({ @@ -276,4 +288,83 @@ describe('rollbackFork', () => { expect(result.skippedIds).toEqual(['wf-c']) expect(result.archived).toBe(0) }) + + it('preserves the undo point and reports pending activations while cutover settles', async () => { + const run = makeRun({ + snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] }, + }) + mockGetLatestRun.mockResolvedValue(run) + mockGetDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: { id: 'op-wf-a', status: 'preparing' }, + }) + + const result = await rollbackFork({ + targetWorkspaceId: 'target-ws', + otherWorkspaceId: 'other-ws', + userId: 'user-1', + }) + + expect(result.pendingActivations).toEqual(['wf-a']) + expect(result.restored).toBe(1) + expect(mockDeleteAllRuns).not.toHaveBeenCalled() + }) + + it('keeps the undo point when no operation row exists to verify cutover', async () => { + const run = makeRun({ + snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] }, + }) + mockGetLatestRun.mockResolvedValue(run) + mockGetDeploymentStatus.mockResolvedValue({ activeDeployment: null, latestOperation: null }) + + const result = await rollbackFork({ + targetWorkspaceId: 'target-ws', + otherWorkspaceId: 'other-ws', + userId: 'user-1', + }) + + expect(result.pendingActivations).toEqual(['wf-a']) + expect(mockDeleteAllRuns).not.toHaveBeenCalled() + }) + + it('treats a superseding operation as settled and consumes the undo point', async () => { + const run = makeRun({ + snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] }, + }) + mockGetLatestRun.mockResolvedValue(run) + mockGetDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: { id: 'op-other', status: 'preparing' }, + }) + + const result = await rollbackFork({ + targetWorkspaceId: 'target-ws', + otherWorkspaceId: 'other-ws', + userId: 'user-1', + }) + + expect(result.pendingActivations).toEqual([]) + expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1) + }) + + it('does not delete the undo point when a newer sync landed after the rollback committed', async () => { + const run = makeRun({ + snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] }, + }) + // Unlocked read + in-tx recheck see our run; the post-commit deletion recheck + // sees a newer sync's run, whose fresh undo point must survive. + mockGetLatestRun + .mockResolvedValueOnce(run) + .mockResolvedValueOnce(run) + .mockResolvedValueOnce(makeRun({ id: 'run-2' })) + + const result = await rollbackFork({ + targetWorkspaceId: 'target-ws', + otherWorkspaceId: 'other-ws', + userId: 'user-1', + }) + + expect(result.pendingActivations).toEqual([]) + expect(mockDeleteAllRuns).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/rollback.ts b/apps/sim/ee/workspace-forking/lib/promote/rollback.ts index 42c8eac4b0d..c2981d67cda 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/rollback.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/rollback.ts @@ -2,10 +2,12 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, inArray, isNull } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' import { enqueueWorkflowUndeploySideEffects, processWorkflowDeploymentOutboxEvent, } from '@/lib/workflows/deployment-outbox' +import { getWorkflowDeploymentStatus } from '@/lib/workflows/persistence/deployment-operations' import { undeployWorkflow } from '@/lib/workflows/persistence/utils' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' import { @@ -32,6 +34,13 @@ export interface RollbackForkParams { } export interface RollbackForkResult { + /** + * Workflows whose restored version has not finished (or has failed) its + * activation cutover. Their drafts are restored but live traffic stays on + * the pre-rollback version until the pending attempt completes; the undo + * point is preserved so the rollback can be re-run. + */ + pendingActivations: string[] restored: number archived: number unarchived: number @@ -59,10 +68,19 @@ type RollbackOp = * retried by the outbox cron/reaper if this process dies first), so the locked * transaction never holds across a network call. No draft blobs are stored - the * deployed version is the source of truth. + * + * Activation cutovers settle asynchronously, so the undo point is deleted only + * after every restored version is verified live; while any activation is still + * pending (or failed), the undo point survives and re-running the rollback + * re-drives the remaining activations. */ export async function rollbackFork(params: RollbackForkParams): Promise { const { targetWorkspaceId, otherWorkspaceId, userId } = params - const requestId = params.requestId ?? 'unknown' + /** + * The request id seeds reactivation idempotency keys; a generated fallback + * keeps distinct rollback invocations from colliding on a shared literal. + */ + const requestId = params.requestId ?? generateRequestId() const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId) if (!edge) { @@ -111,6 +129,7 @@ export async function rollbackFork(params: RollbackForkParams): Promise() const outboxEventIds: string[] = [] + const reactivations: Array<{ workflowId: string; operationId: string }> = [] await db.transaction(async (tx) => { await setForkLockTimeout(tx) @@ -171,7 +190,8 @@ export async function rollbackFork(params: RollbackForkParams): Promise { + await setForkLockTimeout(tx) + await acquireForkTargetLock(tx, targetWorkspaceId) + const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId) + if (current && current.id !== run.id) return + await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId) + }) + } else { + logger.warn( + `[${requestId}] Rollback restored drafts but ${pendingActivations.length} activation(s) are still settling; undo point preserved for retry`, + { targetWorkspaceId, pendingActivations } + ) + } + if (skipped.size > 0) { logger.warn( `[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`, @@ -282,6 +361,7 @@ export async function rollbackFork(params: RollbackForkParams): Promise snapshotState?: SerializableExecutionState metadata?: ExecutionMetadata + /** + * Trusted run metadata injected into the Start block output when its + * "Add run metadata" toggle is enabled. Built server-side at the two + * Executor construction sites — never from caller-supplied input. + */ + startRunMetadata?: StartBlockRunMetadata /** * AbortSignal for cancellation support. * When aborted, the execution should stop gracefully. diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index fdb2cb18c2b..e5e74ba4222 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -20,8 +20,8 @@ vi.mock('drizzle-orm', () => ({ import { resolveSkillContent } from './skills-resolver' -// resolveSkillContent is the shared resolver invoked when the mothership calls -// load_user_skill (and when a workflow agent block calls load_skill). +// resolveSkillContent is the shared resolver invoked when a workflow agent block +// calls load_skill. describe('resolveSkillContent', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index ec446cec2be..f892dff84e3 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -211,6 +211,41 @@ describe('ConditionBlockHandler', () => { expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') }) + it('recognizes legacy-capitalized else branches without evaluating them', async () => { + const conditions = [{ id: 'else1', title: 'Else', value: '' }] + const inputs = { conditions: JSON.stringify(conditions) } + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id) + }) + + it('finds whitespace and mixed-case else branches during fallback', async () => { + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } }) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'false' }, + { id: 'else1', title: ' \t eLsE \n', value: '' }, + ] + const inputs = { conditions: JSON.stringify(conditions) } + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { + source: mockBlock.id, + target: mockTargetBlock1.id, + sourceHandle: 'condition-cond1', + }, + ] + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteTool).toHaveBeenCalledOnce() + expect((result as any).selectedOption).toBe('else1') + expect((result as any).selectedPath).toBeNull() + }) + it('should handle invalid conditions JSON format', async () => { const inputs = { conditions: '{ "invalid json ' } diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index 20ce8660dd1..d329ee4a2c2 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -1,7 +1,8 @@ import { createLogger } from '@sim/logger' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' +import { isElseConditionTitle } from '@/lib/workflows/conditions' import type { BlockOutput } from '@/blocks/types' -import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants' +import { BlockType, DEFAULTS, EDGE } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' import { @@ -211,12 +212,9 @@ export class ConditionBlockHandler implements BlockHandler { selectedCondition: { id: string; title: string; value: string } | null }> { for (const condition of conditions) { - if (condition.title === CONDITION.ELSE_TITLE) { + if (isElseConditionTitle(condition.title)) { const connection = this.findConnectionForCondition(outgoingConnections, condition.id) - if (connection) { - return { selectedConnection: connection, selectedCondition: condition } - } - continue + return { selectedConnection: connection ?? null, selectedCondition: condition } } const conditionValueString = String(condition.value || '') @@ -241,15 +239,6 @@ export class ConditionBlockHandler implements BlockHandler { } } - const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE) - if (elseCondition) { - const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id) - if (elseConnection) { - return { selectedConnection: elseConnection, selectedCondition: elseCondition } - } - return { selectedConnection: null, selectedCondition: elseCondition } - } - return { selectedConnection: null, selectedCondition: null } } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index bd83bb10a92..364491017b3 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -280,6 +280,49 @@ describe('MothershipBlockHandler', () => { expect(mockGenerateId).toHaveBeenCalledTimes(2) }) + it('forwards only enabled MCP tools and selected skills', async () => { + mockGenerateId + .mockReturnValueOnce('chat-uuid') + .mockReturnValueOnce('message-uuid') + .mockReturnValueOnce('request-uuid') + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ content: 'done', toolCalls: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await handler.execute(context, block, { + prompt: 'Use my tools', + tools: [ + { + type: 'mcp', + title: 'Search', + usageControl: 'auto', + params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, + schema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + { + type: 'mcp', + usageControl: 'none', + params: { serverId: 'mcp-server-1', toolName: 'disabled' }, + }, + { type: 'gmail', operation: 'gmail_send' }, + ], + skills: [{ skillId: 'skill-1', name: 'sales-playbook' }], + }) + + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(options.body)) + expect(body.mcpTools).toEqual([ + expect.objectContaining({ + type: 'mcp', + params: expect.objectContaining({ serverId: 'mcp-server-1', toolName: 'search' }), + }), + ]) + expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }]) + }) + it('consumes mothership execute heartbeat streams until the final result', async () => { mockGenerateId.mockReturnValueOnce('chat-uuid') mockGenerateId.mockReturnValueOnce('message-uuid') diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 3246fbf4cb5..866446b2761 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -348,6 +348,28 @@ export class MothershipBlockHandler implements BlockHandler { const messageId = generateId() const requestId = generateId() const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) + const mcpTools = Array.isArray(inputs.tools) + ? inputs.tools.filter( + (tool: Record) => + tool.type === 'mcp' && + tool.usageControl !== 'none' && + typeof (tool.params as Record | undefined)?.serverId === 'string' && + typeof (tool.params as Record | undefined)?.toolName === 'string' + ) + : [] + const skillContexts = Array.isArray(inputs.skills) + ? inputs.skills.flatMap((skill: Record) => + typeof skill.skillId === 'string' && skill.skillId + ? [ + { + kind: 'skill', + skillId: skill.skillId, + label: typeof skill.name === 'string' ? skill.name : skill.skillId, + }, + ] + : [] + ) + : [] const url = buildAPIUrl('/api/mothership/execute') const headers = await buildAuthHeaders(ctx.userId) @@ -368,6 +390,8 @@ export class MothershipBlockHandler implements BlockHandler { messageId, requestId, ...(fileAttachments && { fileAttachments }), + ...(mcpTools.length > 0 ? { mcpTools } : {}), + ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), ...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}), ...(ctx.executionId ? { executionId: ctx.executionId } : {}), } @@ -380,6 +404,8 @@ export class MothershipBlockHandler implements BlockHandler { executionId: ctx.executionId, chatId, fileAttachmentCount: fileAttachments?.length ?? 0, + mcpToolCount: mcpTools.length, + skillCount: skillContexts.length, }) const abortController = new AbortController() diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 3f13cead423..2d2f142409a 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -15,6 +15,7 @@ const { mockResolveBillingAttribution, mockGetCustomBlockAuthority, mockGetPersonalAndWorkspaceEnv, + mockGetUserEmailById, executorOptions, } = vi.hoisted(() => ({ mockExecutorExecute: vi.fn(), @@ -22,6 +23,7 @@ const { mockResolveBillingAttribution: vi.fn(), mockGetCustomBlockAuthority: vi.fn(), mockGetPersonalAndWorkspaceEnv: vi.fn(), + mockGetUserEmailById: vi.fn(), executorOptions: [] as Array>, })) @@ -46,6 +48,45 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ getCustomBlockAuthority: mockGetCustomBlockAuthority, })) +vi.mock('@/lib/users/queries', () => ({ + getUserEmailById: mockGetUserEmailById, +})) + +// Override the global registry mock so the Serializer can carry the start +// block's runMetadata param through child deployed-state serialization. +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn((type: string) => { + if (type === 'start_trigger') { + return { + name: 'Start', + description: 'Unified workflow entry point', + category: 'triggers', + bgColor: '#34B5FF', + icon: () => null, + subBlocks: [ + { id: 'inputFormat', title: 'Inputs', type: 'input-format' }, + { id: 'runMetadata', title: 'Add run metadata', type: 'switch', defaultValue: false }, + ], + inputs: {}, + outputs: {}, + tools: { access: [] }, + triggers: { enabled: true, available: ['chat', 'manual', 'api'] }, + } + } + return { + name: 'Mock Block', + description: 'Mock block description', + icon: () => null, + subBlocks: [], + inputs: {}, + outputs: {}, + tools: { access: [] }, + } + }), + getAllBlocks: vi.fn(() => ({})), + getLatestBlock: vi.fn(() => undefined), +})) + vi.mock('@/lib/logs/execution/snapshot/service', () => ({ snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot }, })) @@ -401,6 +442,402 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source') }) + it('builds trusted caller metadata for custom block children with the toggle on', async () => { + const customBlock = { + ...mockBlock, + metadata: { id: 'custom_block_abc', name: 'Published Block' }, + } + const ctx = { + ...mockContext, + userId: 'consumer-1', + workspaceId: 'workspace-consumer', + executionId: 'exec-1', + } as ExecutionContext + + mockGetCustomBlockAuthority.mockResolvedValue({ + workflowId: 'source-workflow-id', + organizationId: 'org-1', + ownerUserId: 'owner-9', + exposedOutputs: [], + requiredInputIds: [], + }) + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + personalDecrypted: {}, + workspaceDecrypted: {}, + }) + mockResolveBillingAttribution.mockResolvedValue({ + actorUserId: 'owner-9', + workspaceId: 'workspace-source', + }) + mockGetUserEmailById.mockImplementation(async (userId: string) => + userId === 'owner-9' ? 'owner@source.com' : userId === 'consumer-1' ? 'a@corp.com' : null + ) + mockFetch.mockImplementation(async (url: unknown) => { + if (String(url).includes('/deployed')) { + return { + ok: true, + json: () => + Promise.resolve({ + data: { + deployedState: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + runMetadata: { id: 'runMetadata', type: 'switch', value: true }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + } + } + return { + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Source Workflow', + workspaceId: 'workspace-source', + variables: {}, + }, + }), + } + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, customBlock, {}) + + expect(executorOptions).toHaveLength(1) + const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata + expect(startRunMetadata).toMatchObject({ + userEmail: 'a@corp.com', + workspaceId: 'workspace-consumer', + workflowId: 'parent-workflow-id', + executionId: 'exec-1', + executionType: 'workflow', + }) + expect(mockGetUserEmailById).toHaveBeenCalledWith('consumer-1') + expect(mockGetUserEmailById).not.toHaveBeenCalledWith('owner-9') + expect(startRunMetadata).not.toHaveProperty('userId') + expect(typeof startRunMetadata.startTime).toBe('string') + }) + + it('propagates the parent run metadata wholesale to nested children', async () => { + const customBlock = { + ...mockBlock, + metadata: { id: 'custom_block_abc', name: 'Published Block' }, + } + const inheritedMetadata = { + userEmail: 'original@corp.com', + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + executionId: 'exec-1', + executionType: 'api', + executionMode: 'async' as const, + startTime: '2026-07-15T00:00:00.000Z', + } + const ctx = { + ...mockContext, + userId: 'publisher-1', + workspaceId: 'workspace-intermediate', + executionId: 'exec-1', + startRunMetadata: inheritedMetadata, + } as ExecutionContext + + mockGetCustomBlockAuthority.mockResolvedValue({ + workflowId: 'source-workflow-id', + organizationId: 'org-1', + ownerUserId: 'owner-9', + exposedOutputs: [], + requiredInputIds: [], + }) + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + personalDecrypted: {}, + workspaceDecrypted: {}, + }) + mockResolveBillingAttribution.mockResolvedValue({ + actorUserId: 'owner-9', + workspaceId: 'workspace-source', + }) + mockFetch.mockImplementation(async (url: unknown) => { + if (String(url).includes('/deployed')) { + return { + ok: true, + json: () => + Promise.resolve({ + data: { + deployedState: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + runMetadata: { id: 'runMetadata', type: 'switch', value: true }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + } + } + return { + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Source Workflow', + workspaceId: 'workspace-source', + variables: {}, + }, + }), + } + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, customBlock, {}) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({ + userEmail: 'original@corp.com', + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + executionMode: 'async', + }) + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('preserves a fail-soft null inherited email instead of re-resolving it', async () => { + const ctx = { + ...mockContext, + userId: 'publisher-1', + workspaceId: 'workspace-parent', + startRunMetadata: { + userEmail: null, + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + }, + } as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + runMetadata: { id: 'runMetadata', type: 'switch', value: true }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull() + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('recovers inherited metadata from the seeded start-block state after resume', async () => { + const seededMetadata = { + userEmail: 'original@corp.com', + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + executionMode: 'sync', + } + const parentStartBlock = { + id: 'parent-start', + position: { x: 0, y: 0 }, + config: { tool: 'start_trigger', params: { runMetadata: true } }, + inputs: {}, + outputs: {}, + metadata: { id: 'start_trigger', name: 'Start', category: 'triggers' }, + enabled: true, + } + const ctx = { + ...mockContext, + userId: 'user-1', + workspaceId: 'workspace-parent', + workflow: { ...mockContext.workflow, blocks: [parentStartBlock] }, + blockStates: new Map([ + [ + 'parent-start', + { output: { metadata: seededMetadata }, executed: true, executionTime: 0 }, + ], + ]), + } as unknown as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + runMetadata: { id: 'runMetadata', type: 'switch', value: true }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({ + userEmail: 'original@corp.com', + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + }) + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => { + const inheritedMetadata = { + userEmail: 'original@corp.com', + workspaceId: 'workspace-original', + workflowId: 'workflow-original', + } + const ctx = { + ...mockContext, + userId: 'publisher-1', + workspaceId: 'workspace-parent', + startRunMetadata: inheritedMetadata, + } as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata).toBe(inheritedMetadata) + }) + + it('passes no run metadata when the child start block toggle is off', async () => { + const ctx = { + ...mockContext, + userId: 'consumer-1', + workspaceId: 'workspace-parent', + } as ExecutionContext + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + await handler.execute(ctx, mockBlock, inputs) + + expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata).toBeUndefined() + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + it('should fail closed when the executing context has no workspace', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 406dea79b8f..eb1763f8935 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' @@ -8,6 +9,7 @@ import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' +import { getUserEmailById } from '@/lib/users/queries' import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config' @@ -16,17 +18,20 @@ import { Executor } from '@/executor' import { BlockType, DEFAULTS, HTTP } from '@/executor/constants' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' import type { WorkflowNodeMetadata } from '@/executor/execution/types' -import type { - BlockHandler, - ExecutionContext, - ExecutionResult, - StreamingExecution, +import { + type BlockHandler, + type ExecutionContext, + type ExecutionResult, + START_BLOCK_METADATA_FIELD, + type StartBlockRunMetadata, + type StreamingExecution, } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { getIterationContext } from '@/executor/utils/iteration-context' import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' +import { isRunMetadataEnabled, resolveExecutorStartBlock } from '@/executor/utils/start-block' import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' @@ -40,6 +45,22 @@ function getValueAtPath(source: unknown, path: string): unknown { }, source) } +/** + * Recover the trusted run metadata from the executing workflow's seeded + * start-block output. Resume restores block states from the snapshot but never + * rebuilds `ctx.startRunMetadata`, so the seeded output is the surviving copy. + */ +function readSeededStartRunMetadata(ctx: ExecutionContext): StartBlockRunMetadata | undefined { + const resolution = resolveExecutorStartBlock(ctx.workflow?.blocks ?? [], { + execution: 'manual', + isChildWorkflow: false, + }) + if (!resolution || !isRunMetadataEnabled(resolution.block)) return undefined + + const seeded = ctx.blockStates.get(resolution.blockId)?.output?.[START_BLOCK_METADATA_FIELD] + return isRecordLike(seeded) ? (seeded as StartBlockRunMetadata) : undefined +} + /** * Remap a custom block's resolved input mapping from source-field ids to the * child workflow's current field names. The consumer's sub-block values are keyed @@ -384,6 +405,40 @@ export class WorkflowBlockHandler implements BlockHandler { }) } + // Trusted run metadata for the child's Start block. Every field describes + // the INVOKING run (the caller's email, workspace, and workflow — never the + // child's own static, authoring-time-known identity), delivered on a + // server-verified channel a consumer's inputs can never spoof. + let childStartRunMetadata: StartBlockRunMetadata | undefined + const childStartResolution = resolveExecutorStartBlock(childWorkflow.serializedState.blocks, { + execution: 'manual', + isChildWorkflow: false, + }) + // Resumed executions never rebuild `ctx.startRunMetadata`, so fall back to + // the parent's own seeded start-block output — the persisted copy of the + // same trusted object, restored from the snapshot on resume. + const inherited = ctx.startRunMetadata ?? readSeededStartRunMetadata(ctx) + if (childStartResolution && isRunMetadataEnabled(childStartResolution.block)) { + // When the parent run already carries trusted metadata, propagate ALL of + // it so nested children see one consistent invoking identity (the + // original consumer) instead of a mix of original and intermediate. + // Inherited email is taken verbatim — a fail-soft null must stay null, + // not be re-resolved to the intermediate (publisher) identity. + childStartRunMetadata = { + userEmail: inherited + ? (inherited.userEmail ?? null) + : ctx.userId + ? await getUserEmailById(ctx.userId) + : null, + workspaceId: inherited?.workspaceId ?? ctx.workspaceId ?? null, + workflowId: inherited?.workflowId ?? ctx.workflowId ?? null, + executionId: ctx.executionId, + executionType: 'workflow', + executionMode: inherited?.executionMode ?? ctx.metadata.executionMode, + startTime: new Date().toISOString(), + } + } + const subExecutor = new Executor({ workflow: childWorkflow.serializedState, workflowInput: childWorkflowInput, @@ -403,6 +458,9 @@ export class WorkflowBlockHandler implements BlockHandler { // internal tool calls (knowledge, guardrails, MCP, Mothership) can // attach the required billing attribution header. billingAttribution: childBillingAttribution, + // Fall back to the inherited metadata so a toggle-off intermediate + // child still carries the trusted identity chain to deeper children. + startRunMetadata: childStartRunMetadata ?? inherited, abortSignal: ctx.abortSignal, // Propagate in-flight block-output redaction into child workflows so // nested blocks mask outputs too (recurses: each child forwards it). diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index babb2a4e6c6..bddbd2fc763 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -231,6 +231,28 @@ export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [ export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number] +/** Start block output key that carries trusted, server-injected run metadata. */ +export const START_BLOCK_METADATA_FIELD = 'metadata' + +/** + * Trusted run metadata surfaced under `` when the Start + * block's "Add run metadata" toggle is enabled. Built server-side from the + * authenticated execution context — never from caller-supplied input. + * Every field describes the INVOKING run: on top-level runs that is the run + * itself; on child and custom-block executions it is the parent run (its + * actor's email, workspace, and workflow) — never the child's own static, + * authoring-time-known identity. + */ +export interface StartBlockRunMetadata { + userEmail?: string | null + workspaceId?: string | null + workflowId?: string | null + executionId?: string + executionType?: string + executionMode?: 'sync' | 'stream' | 'async' + startTime?: string +} + export interface BlockLog { blockId: string blockName?: string @@ -291,6 +313,7 @@ interface ExecutionMetadata { useDraftState?: boolean resumeFromSnapshot?: boolean resumeTerminalNoop?: boolean + executionMode?: 'sync' | 'stream' | 'async' } export interface BlockState { @@ -322,6 +345,8 @@ export interface ExecutionContext { blockLogs: BlockLog[] metadata: ExecutionMetadata + /** Trusted run metadata for the Start block's "Add run metadata" toggle. */ + startRunMetadata?: StartBlockRunMetadata environmentVariables: Record workflowVariables?: Record diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 1e8cadf9c48..98b5c80d15d 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -541,4 +541,99 @@ describe('start-block utilities', () => { expect(output.extra).toBe('untouched') }) }) + + describe('run metadata injection', () => { + const runMetadata = { + userEmail: 'real@sim.ai', + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-1', + executionType: 'api', + executionMode: 'sync' as const, + startTime: '2026-07-15T00:00:00.000Z', + } + + function createUnifiedResolution(subBlocks?: Record) { + const block = createBlock('start_trigger', 'start', subBlocks ? { subBlocks } : undefined) + return { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + } + + it.concurrent('server metadata overrides caller-supplied metadata key', () => { + const resolution = createUnifiedResolution({ runMetadata: { value: true } }) + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + metadata: { userEmail: 'attacker@x.com' }, + simUserEmail: 'attacker@x.com', + payload: 'value', + }, + runMetadata, + }) + + expect(output.metadata).toEqual(runMetadata) + expect(output.payload).toBe('value') + expect(output.simUserEmail).toBe('attacker@x.com') + }) + + it.concurrent('strips caller-supplied metadata key when no trusted metadata exists', () => { + const resolution = createUnifiedResolution({ runMetadata: { value: true } }) + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { metadata: { userEmail: 'attacker@x.com' } }, + }) + + expect(output).not.toHaveProperty('metadata') + }) + + it.concurrent('throws when an input format field is named metadata', () => { + const resolution = createUnifiedResolution({ + runMetadata: { value: true }, + inputFormat: { value: [{ name: 'metadata', type: 'string' }] }, + }) + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: {}, + runMetadata, + }) + ).toThrow('reserves the "metadata" output') + }) + + it.concurrent('toggle off leaves caller-supplied metadata untouched', () => { + const resolution = createUnifiedResolution() + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { metadata: { custom: 'value' } }, + runMetadata, + }) + + expect(output.metadata).toEqual({ custom: 'value' }) + }) + + it.concurrent('reads the toggle from config params when metadata subBlocks are absent', () => { + const block = createBlock('start_trigger', 'start') + block.config.params.runMetadata = true + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { metadata: 'spoof' }, + runMetadata, + }) + + expect(output.metadata).toEqual(runMetadata) + }) + }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 1c59a9cb2d4..169307452cf 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -13,6 +13,8 @@ import type { InputFormatField } from '@/lib/workflows/types' import { EXECUTION_CONTROL_OUTPUT_FIELD_NAMES, type NormalizedBlockOutput, + START_BLOCK_METADATA_FIELD, + type StartBlockRunMetadata, type UserFile, } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' @@ -141,6 +143,14 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] { .map((field) => field) } +/** + * Reads the Start block's "Add run metadata" toggle from the serialized block. + */ +export function isRunMetadataEnabled(block: SerializedBlock): boolean { + const value = readMetadataSubBlockValue(block, 'runMetadata') ?? block.config?.params?.runMetadata + return value === true || value === 'true' +} + function normalizeLegacyStarterMode(modeValue: unknown): 'manual' | 'api' | 'chat' | null { if (modeValue === 'chat') return 'chat' if (modeValue === 'api' || modeValue === 'run') return 'api' @@ -580,11 +590,32 @@ function extractSubBlocks(block: SerializedBlock): Record | und export interface StartBlockOutputOptions { resolution: ExecutorStartResolution workflowInput: unknown + /** Trusted, server-built run metadata. Only applied when the block's toggle is on. */ + runMetadata?: StartBlockRunMetadata +} + +function assertNoMetadataInputFormatField( + inputFormat: InputFormatField[], + block: SerializedBlock +): void { + const hasMetadataField = inputFormat.some( + (field) => readInputFormatFieldName(field) === START_BLOCK_METADATA_FIELD + ) + + if (!hasMetadataField) { + return + } + + const blockName = block.metadata?.name ?? block.id + throw new Error( + `Start block "${blockName}" has "Add run metadata" enabled, which reserves the "${START_BLOCK_METADATA_FIELD}" output. Rename the "${START_BLOCK_METADATA_FIELD}" input format field or disable the toggle.` + ) } export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput { const { resolution, workflowInput } = options const inputFormat = extractInputFormat(resolution.block) + const runMetadataEnabled = isRunMetadataEnabled(resolution.block) const legacyStarterMode = resolution.path === StartBlockPath.LEGACY_STARTER ? getSerializedLegacyStarterMode(resolution.block) @@ -592,6 +623,9 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) { assertNoReservedInputFormatFields(inputFormat, resolution.block) + if (runMetadataEnabled) { + assertNoMetadataInputFormatField(inputFormat, resolution.block) + } } const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat( @@ -631,6 +665,15 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali output = buildManualTriggerOutput(finalInput, workflowInput) } + if (runMetadataEnabled) { + // The metadata key is server-owned when the toggle is on: any caller-supplied + // value is dropped, and absent trusted metadata leaves the key absent (fail closed). + delete output[START_BLOCK_METADATA_FIELD] + if (options.runMetadata) { + output[START_BLOCK_METADATA_FIELD] = { ...options.runMetadata } + } + } + assertNoReservedStartOutputFields(output, resolution.block) return output } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index db1cd25e6ca..67146168fc1 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -140,6 +140,9 @@ export function useUpdateWorkspaceCredential() { botToken: payload.botToken, apiToken: payload.apiToken, domain: payload.domain, + clientId: payload.clientId, + clientSecret: payload.clientSecret, + orgId: payload.orgId, }, }) }, diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index fdc6c22b4fe..886e12983cc 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -32,6 +32,7 @@ const logger = createLogger('DeploymentQueries') export type { ChatDetail, DeploymentVersionsResponse } export const DEPLOYMENT_INFO_STALE_TIME = 30 * 1000 +export const DEPLOYMENT_STATUS_REFETCH_INTERVAL = 5 * 1000 export const DEPLOYED_WORKFLOW_STATE_STALE_TIME = 30 * 1000 export const DEPLOYMENT_VERSIONS_STALE_TIME = 30 * 1000 export const CHAT_DEPLOYMENT_STATUS_STALE_TIME = 30 * 1000 @@ -102,6 +103,9 @@ async function fetchDeploymentInfo( apiKey: data.apiKey ?? null, needsRedeployment: data.needsRedeployment ?? false, isPublicApi: data.isPublicApi ?? false, + warnings: data.warnings, + activeDeployment: data.activeDeployment ?? null, + latestDeploymentAttempt: data.latestDeploymentAttempt ?? null, } } @@ -109,16 +113,21 @@ async function fetchDeploymentInfo( * Hook to fetch deployment info for a workflow. * Provides isDeployed status, deployedAt timestamp, apiKey info, and needsRedeployment flag. */ -export function useDeploymentInfo( - workflowId: string | null, - options?: { enabled?: boolean; refetchOnMount?: boolean | 'always' } -) { +export function useDeploymentInfo(workflowId: string | null, options?: { enabled?: boolean }) { return useQuery({ queryKey: deploymentKeys.info(workflowId), queryFn: ({ signal }) => fetchDeploymentInfo(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), staleTime: DEPLOYMENT_INFO_STALE_TIME, - ...(options?.refetchOnMount !== undefined && { refetchOnMount: options.refetchOnMount }), + refetchInterval: (query) => { + const status = query.state.data?.latestDeploymentAttempt?.status + return status === 'preparing' || status === 'activating' + ? DEPLOYMENT_STATUS_REFETCH_INTERVAL + : false + }, + refetchOnMount: 'always', + refetchOnWindowFocus: true, + refetchOnReconnect: true, }) } @@ -170,7 +179,9 @@ async function fetchDeploymentVersions( /** * Hook to fetch deployment versions for a workflow. - * Returns a list of all deployment versions with their metadata. + * Returns a list of all deployment versions with their metadata. Polls while + * any version has an in-flight deployment attempt so preparing/activating + * labels resolve to their terminal state without a manual refresh. */ export function useDeploymentVersions(workflowId: string | null, options?: { enabled?: boolean }) { return useQuery({ @@ -178,6 +189,14 @@ export function useDeploymentVersions(workflowId: string | null, options?: { ena queryFn: ({ signal }) => fetchDeploymentVersions(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), staleTime: DEPLOYMENT_VERSIONS_STALE_TIME, + refetchInterval: (query) => { + const hasInFlightVersion = query.state.data?.versions.some( + (version) => + version.latestOperationStatus === 'preparing' || + version.latestOperationStatus === 'activating' + ) + return hasInFlightVersion ? DEPLOYMENT_STATUS_REFETCH_INTERVAL : false + }, }) } @@ -304,6 +323,8 @@ export function useDeployWorkflow() { deployedAt: data.deployedAt ?? undefined, apiKey: data.apiKey ?? undefined, warnings: data.warnings, + activeDeployment: data.activeDeployment, + latestDeploymentAttempt: data.latestDeploymentAttempt, } }, onSettled: (_data, error, variables) => { @@ -539,40 +560,14 @@ export function useActivateDeploymentVersion() { body: { isActive: true }, }) }, - onMutate: async ({ workflowId, version }) => { - await queryClient.cancelQueries({ queryKey: deploymentKeys.versions(workflowId) }) - - const previousVersions = queryClient.getQueryData( - deploymentKeys.versions(workflowId) - ) - - if (previousVersions) { - queryClient.setQueryData(deploymentKeys.versions(workflowId), { - versions: previousVersions.versions.map((v) => ({ - ...v, - isActive: v.version === version, - })), - }) - } - - return { previousVersions } - }, - onError: (_, variables, context) => { - logger.error('Failed to activate deployment version') - - if (context?.previousVersions) { - queryClient.setQueryData( - deploymentKeys.versions(variables.workflowId), - context.previousVersions - ) - } - }, onSettled: (_data, error, variables) => { if (!error) { logger.info('Deployment version activated', { workflowId: variables.workflowId, version: variables.version, }) + } else { + logger.error('Failed to activate deployment version', { error }) } return invalidateDeploymentQueries(queryClient, variables.workflowId) }, diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 1d802c13d93..606dc0020e3 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -675,12 +675,12 @@ export function useCreateMothershipChat(workspaceId?: string) { async function forkChat(params: { chatId: string upToMessageId: string -}): Promise<{ id: string }> { +}): Promise<{ id: string; failedFileCopies?: number }> { const data = await requestJson(forkMothershipChatContract, { params: { chatId: params.chatId }, body: { upToMessageId: params.upToMessageId }, }) - return { id: data.id } + return { id: data.id, failedFileCopies: data.failedFileCopies } } export function useForkMothershipChat(workspaceId?: string) { diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts index a3ccf8da645..3db8179f1a7 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.test.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { getQueryDataMock } = vi.hoisted(() => ({ @@ -13,7 +14,23 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ })), })) -import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { + getWorkflowById, + getWorkflows, + removeWorkflowFromActiveCache, +} from '@/hooks/queries/utils/workflow-cache' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +function workflow(id: string): WorkflowMetadata { + return { + id, + name: id, + lastModified: new Date(0), + createdAt: new Date(0), + sortOrder: 0, + } +} describe('getWorkflows', () => { beforeEach(() => { @@ -44,3 +61,17 @@ describe('getWorkflows', () => { expect(getWorkflowById('ws-1', 'missing')).toBeUndefined() }) }) + +describe('removeWorkflowFromActiveCache', () => { + it('removes the deleted workflow immediately and returns the rollback snapshot', () => { + const queryClient = new QueryClient() + const key = workflowKeys.list('ws-1', 'active') + const initial = [workflow('wf-1'), workflow('wf-2')] + queryClient.setQueryData(key, initial) + + const snapshot = removeWorkflowFromActiveCache(queryClient, 'ws-1', 'wf-1') + + expect(snapshot).toEqual(initial) + expect(queryClient.getQueryData(key)).toEqual([workflow('wf-2')]) + }) +}) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.ts b/apps/sim/hooks/queries/utils/workflow-cache.ts index c810a236f87..e12c802821d 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -27,3 +28,21 @@ export function getWorkflowById( ): WorkflowMetadata | undefined { return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId) } + +/** + * Removes a workflow from the active-list cache immediately and returns the + * previous value for callers that need rollback. Shared by user-initiated + * deletion and streamed Mothership resource removals. + */ +export function removeWorkflowFromActiveCache( + queryClient: QueryClient, + workspaceId: string, + workflowId: string +): WorkflowMetadata[] | undefined { + const key = workflowKeys.list(workspaceId, 'active') + const snapshot = queryClient.getQueryData(key) + queryClient.setQueryData(key, (current) => + (current ?? []).filter((workflow) => workflow.id !== workflowId) + ) + return snapshot +} diff --git a/apps/sim/hooks/queries/utils/workflow-list-query.ts b/apps/sim/hooks/queries/utils/workflow-list-query.ts index 43fa81988f2..24e0a74e48e 100644 --- a/apps/sim/hooks/queries/utils/workflow-list-query.ts +++ b/apps/sim/hooks/queries/utils/workflow-list-query.ts @@ -20,6 +20,8 @@ export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata { lastModified: new Date(workflow.updatedAt), archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null, locked: workflow.locked, + forkSyncExcluded: workflow.forkSyncExcluded, + isDeployed: workflow.isDeployed, } } diff --git a/apps/sim/hooks/queries/utils/workspace-list-query.ts b/apps/sim/hooks/queries/utils/workspace-list-query.ts new file mode 100644 index 00000000000..74617e39dcd --- /dev/null +++ b/apps/sim/hooks/queries/utils/workspace-list-query.ts @@ -0,0 +1,27 @@ +import type { Workspace, WorkspacesResponse } from '@/lib/api/contracts' + +export const WORKSPACE_LIST_STALE_TIME = 30 * 1000 + +/** + * Applies cached-shape defaults to a single schema-parsed wire workspace — + * only the invite fields are optional on the wire; everything else is + * guaranteed by the contract schema. + */ +export function normalizeWorkspace(workspace: Workspace): Workspace { + return { + ...workspace, + inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, + inviteDisabledReason: workspace.inviteDisabledReason ?? null, + inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, + } +} + +/** + * Normalizes the schema-parsed /api/workspaces payload into the cached shape. + * Shared by the client workspace-list fetch and the workspace layout's + * server-side sidebar prefetch so the two can never cache different shapes + * under `workspaceKeys.list`. + */ +export function normalizeWorkspacesResponse(data: WorkspacesResponse): WorkspacesResponse { + return { ...data, workspaces: data.workspaces.map(normalizeWorkspace) } +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 15aed17e712..fcffbcc24ea 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -32,7 +32,7 @@ import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-enve import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflows, removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { getWorkflowListQueryOptions, @@ -175,7 +175,7 @@ export function useCreateWorkflow() { body: { id, name: name || generateCreativeWorkflowName(), - description: description || 'New workflow', + description, workspaceId, folderId: folderId || null, sortOrder, @@ -226,7 +226,7 @@ export function useCreateWorkflow() { name: variables.name || generateCreativeWorkflowName(), lastModified: new Date(), createdAt: new Date(), - description: variables.description || 'New workflow', + description: variables.description ?? '', workspaceId: variables.workspaceId, folderId: variables.folderId || null, sortOrder, @@ -534,13 +534,10 @@ export function useDeleteWorkflowMutation() { queryKey: workflowKeys.list(variables.workspaceId, 'active'), }) - const snapshot = queryClient.getQueryData( - workflowKeys.list(variables.workspaceId, 'active') - ) - - queryClient.setQueryData( - workflowKeys.list(variables.workspaceId, 'active'), - (old) => (old ?? []).filter((w) => w.id !== variables.workflowId) + const snapshot = removeWorkflowFromActiveCache( + queryClient, + variables.workspaceId, + variables.workflowId ) return { snapshot } diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 5b9831a3f4b..8dd00c49ebe 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -18,6 +18,11 @@ import { type WorkspaceQueryScope, type WorkspacesResponse, } from '@/lib/api/contracts' +import { + normalizeWorkspace, + normalizeWorkspacesResponse, + WORKSPACE_LIST_STALE_TIME, +} from '@/hooks/queries/utils/workspace-list-query' /** * Query key factory for workspace-related queries. @@ -40,7 +45,6 @@ export const workspaceKeys = { export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions } export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000 -export const WORKSPACE_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_SETTINGS_STALE_TIME = 30 * 1000 export const WORKSPACE_MEMBERS_STALE_TIME = 5 * 60 * 1000 export const WORKSPACE_ADMIN_LIST_STALE_TIME = 60 * 1000 @@ -50,27 +54,7 @@ async function fetchWorkspaces( signal?: AbortSignal ): Promise { const data = await requestJson(listWorkspacesContract, { query: { scope }, signal }) - return { - workspaces: - data.workspaces?.map((workspace: Workspace) => ({ - ...workspace, - organizationId: workspace.organizationId ?? null, - workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared', - inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, - inviteDisabledReason: workspace.inviteDisabledReason ?? null, - inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, - })) || [], - lastActiveWorkspaceId: - typeof data.lastActiveWorkspaceId === 'string' ? data.lastActiveWorkspaceId : null, - creationPolicy: data.creationPolicy - ? { - ...data.creationPolicy, - organizationId: data.creationPolicy.organizationId ?? null, - reason: data.creationPolicy.reason ?? null, - workspaceMode: data.creationPolicy.workspaceMode ?? 'personal', - } - : null, - } + return normalizeWorkspacesResponse(data) } const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces @@ -338,14 +322,7 @@ async function fetchAdminWorkspaces( } const workspacesData = await requestJson(listWorkspacesContract, { query: {}, signal }) - const allUserWorkspaces = (workspacesData.workspaces || []).map((workspace: Workspace) => ({ - ...workspace, - organizationId: workspace.organizationId ?? null, - workspaceMode: workspace.workspaceMode ?? 'grandfathered_shared', - inviteMembersEnabled: workspace.inviteMembersEnabled ?? false, - inviteDisabledReason: workspace.inviteDisabledReason ?? null, - inviteUpgradeRequired: workspace.inviteUpgradeRequired ?? false, - })) + const allUserWorkspaces = workspacesData.workspaces.map(normalizeWorkspace) return allUserWorkspaces .filter((workspace: Workspace) => workspace.permissions === 'admin') diff --git a/apps/sim/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts new file mode 100644 index 00000000000..87cf96ec094 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/clickup/selectors.ts @@ -0,0 +1,133 @@ +import { requestJson } from '@/lib/api/client/request' +import * as selectorContracts from '@/lib/api/contracts/selectors' +import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' + +export const clickupSelectors = { + 'clickup.workspaces': { + key: 'clickup.workspaces', + contracts: [selectorContracts.clickupWorkspacesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.workspaces', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.workspaces') + const data = await requestJson(selectorContracts.clickupWorkspacesSelectorContract, { + body: { credential: credentialId, workflowId: context.workflowId }, + signal, + }) + return (data.workspaces || []).map((workspace) => ({ + id: workspace.id, + label: workspace.name, + })) + }, + }, + 'clickup.spaces': { + key: 'clickup.spaces', + contracts: [selectorContracts.clickupSpacesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.spaces', + context.oauthCredential ?? 'none', + context.teamId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.spaces') + if (!context.teamId) { + throw new Error('Missing workspace (team) ID for clickup.spaces selector') + } + const data = await requestJson(selectorContracts.clickupSpacesSelectorContract, { + body: { + credential: credentialId, + teamId: context.teamId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.spaces || []).map((space) => ({ + id: space.id, + label: space.name, + })) + }, + }, + 'clickup.folders': { + key: 'clickup.folders', + contracts: [selectorContracts.clickupFoldersSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.folders', + context.oauthCredential ?? 'none', + context.spaceId ?? context.listSpaceId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential && (context.spaceId || context.listSpaceId)), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.folders') + const spaceId = context.spaceId || context.listSpaceId + if (!spaceId) { + throw new Error('Missing space ID for clickup.folders selector') + } + const data = await requestJson(selectorContracts.clickupFoldersSelectorContract, { + body: { + credential: credentialId, + spaceId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.folders || []).map((folder) => ({ + id: folder.id, + label: folder.name, + })) + }, + }, + 'clickup.lists': { + key: 'clickup.lists', + contracts: [selectorContracts.clickupListsSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.lists', + context.oauthCredential ?? 'none', + context.spaceId ?? context.listSpaceId ?? 'none', + context.folderId ?? 'none', + ], + enabled: ({ context }) => + Boolean( + context.oauthCredential && (context.folderId || context.spaceId || context.listSpaceId) + ), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.lists') + const spaceId = context.spaceId || context.listSpaceId + if (!context.folderId && !spaceId) { + throw new Error('Missing folder or space ID for clickup.lists selector') + } + const data = await requestJson(selectorContracts.clickupListsSelectorContract, { + body: { + credential: credentialId, + folderId: context.folderId, + spaceId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.lists || []).map((list) => ({ + id: list.id, + label: list.name, + })) + }, + }, +} satisfies Record< + Extract< + SelectorKey, + 'clickup.workspaces' | 'clickup.spaces' | 'clickup.folders' | 'clickup.lists' + >, + SelectorDefinition +> diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index e3af0f2b179..45e29e029b2 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -3,6 +3,7 @@ import { asanaSelectors } from '@/hooks/selectors/providers/asana/selectors' import { attioSelectors } from '@/hooks/selectors/providers/attio/selectors' import { bigquerySelectors } from '@/hooks/selectors/providers/bigquery/selectors' import { calcomSelectors } from '@/hooks/selectors/providers/calcom/selectors' +import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors' import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' import { googleSelectors } from '@/hooks/selectors/providers/google/selectors' @@ -50,6 +51,7 @@ export const selectorRegistry = { ...linearSelectors, ...knowledgeSelectors, ...webflowSelectors, + ...clickupSelectors, ...cloudwatchSelectors, ...simSelectors, } satisfies Record diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 0b26fdd130f..8a6eec1ebb3 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -12,6 +12,10 @@ export type SelectorKey = | 'bigquery.tables' | 'calcom.eventTypes' | 'calcom.schedules' + | 'clickup.workspaces' + | 'clickup.spaces' + | 'clickup.folders' + | 'clickup.lists' | 'confluence.spaces' | 'google.tasks.lists' | 'jsm.requestTypes' @@ -87,6 +91,9 @@ export interface SelectorContext { serviceDeskId?: string impersonateUserEmail?: string boardId?: string + spaceId?: string + listSpaceId?: string + folderId?: string awsAccessKeyId?: string awsSecretAccessKey?: string awsRegion?: string diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 6e45fe2cad8..aa456962264 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -206,13 +206,20 @@ export async function getApiKeyWithBYOK( const isMistralModel = provider === 'mistral' const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' + const isKimiModel = provider === 'kimi' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( isHosted && workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel) + (isOpenAIModel || + isClaudeModel || + isGeminiModel || + isMistralModel || + isZaiModel || + isXaiModel || + isKimiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 0c2be6492bf..191eeb1605d 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([ 'google', 'mistral', 'zai', + 'kimi', 'xai', 'fireworks', 'together', diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 22793dd27d5..99bfd56adfa 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -130,6 +130,9 @@ export const createCredentialBodySchema = z id: z.string().uuid('id must be a valid UUID').optional(), signingSecret: z.string().trim().min(1).optional(), botToken: z.string().trim().min(1).optional(), + clientId: z.string().trim().min(1).max(512).optional(), + clientSecret: z.string().trim().min(1).max(1024).optional(), + orgId: z.string().trim().min(1).max(255).optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -200,6 +203,10 @@ export const updateCredentialByIdBodySchema = z /** Atlassian service-account secret rotation (reconnect). */ apiToken: z.string().trim().min(1).optional(), domain: z.string().trim().min(1).optional(), + /** Client-credential service-account secret rotation (reconnect). */ + clientId: z.string().trim().min(1).max(512).optional(), + clientSecret: z.string().trim().min(1).max(1024).optional(), + orgId: z.string().trim().min(1).max(255).optional(), }) .strict() .refine( @@ -210,7 +217,10 @@ export const updateCredentialByIdBodySchema = z data.signingSecret !== undefined || data.botToken !== undefined || data.apiToken !== undefined || - data.domain !== undefined, + data.domain !== undefined || + data.clientId !== undefined || + data.clientSecret !== undefined || + data.orgId !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/api/contracts/data-retention.test.ts b/apps/sim/lib/api/contracts/data-retention.test.ts index 10fdcb9e720..d5969732426 100644 --- a/apps/sim/lib/api/contracts/data-retention.test.ts +++ b/apps/sim/lib/api/contracts/data-retention.test.ts @@ -145,6 +145,41 @@ describe('piiRedactionRuleSchema', () => { expect(result.success).toBe(false) }) + it('strips spaCy-NER entities from the blockOutputs stage only (regex-only)', () => { + const result = piiRedactionRuleSchema.safeParse({ + id: 'r-1', + workspaceId: null, + stages: { + input: stage(true, ['PERSON', 'EMAIL_ADDRESS']), + blockOutputs: stage(true, ['PERSON', 'EMAIL_ADDRESS']), + logs: stage(true, ['DATE_TIME']), + }, + }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.stages?.blockOutputs.entityTypes).toEqual(['EMAIL_ADDRESS']) + expect(result.data.stages?.blockOutputs.enabled).toBe(true) + // input and logs keep their NER entities. + expect(result.data.stages?.input.entityTypes).toEqual(['PERSON', 'EMAIL_ADDRESS']) + expect(result.data.stages?.logs.entityTypes).toEqual(['DATE_TIME']) + }) + + it('disables blockOutputs when the NER strip leaves it empty (migration-safe, no lockout)', () => { + const result = piiRedactionRuleSchema.safeParse({ + id: 'r-1', + workspaceId: null, + stages: { + input: stage(false, []), + blockOutputs: stage(true, ['PERSON', 'LOCATION']), + logs: stage(false, []), + }, + }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.stages?.blockOutputs.entityTypes).toEqual([]) + expect(result.data.stages?.blockOutputs.enabled).toBe(false) + }) + it('enforces one rule per scope (uniqueness refine still applies)', () => { const result = piiRedactionSettingsSchema.safeParse({ rules: [ diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 1b9cd57db72..a7daf83b739 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -1,6 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { + DEPLOYMENT_COMPONENT_STATUSES, + DEPLOYMENT_OPERATION_ACTIONS, + DEPLOYMENT_OPERATION_STATUSES, +} from '@/lib/workflows/deployment-lifecycle' import type { WorkflowState } from '@/stores/workflows/workflow/types' const deployedWorkflowStateSchema = z.custom( @@ -56,6 +61,46 @@ export const activateDeploymentVersionBodySchema = z.object({ export type ActivateDeploymentVersionBody = z.input +export const deploymentOperationStatusSchema = z.enum(DEPLOYMENT_OPERATION_STATUSES) + +export const deploymentComponentReadinessSchema = z.enum([ + ...DEPLOYMENT_COMPONENT_STATUSES, + 'not_applicable', +]) + +export const deploymentReadinessSchema = z.object({ + webhooks: deploymentComponentReadinessSchema, + schedules: deploymentComponentReadinessSchema, + mcp: deploymentComponentReadinessSchema, +}) + +export const deploymentOperationSummarySchema = z.object({ + id: z.string(), + deploymentVersionId: z.string(), + version: z.number().int().positive(), + action: z.enum(DEPLOYMENT_OPERATION_ACTIONS), + status: deploymentOperationStatusSchema, + readiness: deploymentReadinessSchema, + requestedAt: z.string(), + activatedAt: z.string().nullable().optional(), + error: z + .object({ + code: z.string(), + message: z.string(), + retryable: z.boolean(), + }) + .nullable() + .optional(), +}) + +export type DeploymentOperationSummary = z.output + +export const activeDeploymentSummarySchema = z.object({ + deploymentVersionId: z.string(), + version: z.number().int().positive(), + deployedAt: z.string(), +}) + export const deploymentVersionPatchBodySchema = deploymentVersionMetadataFieldsSchema .extend({ isActive: z.literal(true).optional(), @@ -74,6 +119,8 @@ export const deploymentInfoResponseSchema = z.object({ needsRedeployment: z.boolean().optional(), isPublicApi: z.boolean().optional(), warnings: z.array(z.string()).optional(), + activeDeployment: activeDeploymentSummarySchema.nullable().optional(), + latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(), }) export type DeploymentInfoResponse = z.output @@ -89,6 +136,7 @@ export const deploymentVersionSchema = z.object({ createdAt: z.string(), createdBy: z.string().nullable().optional(), deployedBy: z.string().nullable().optional(), + latestOperationStatus: deploymentOperationStatusSchema.nullable().optional(), }) export type DeploymentVersion = z.output @@ -168,10 +216,12 @@ export type UpdateDeploymentVersionMetadataResponse = z.output< export const activateDeploymentVersionResponseSchema = z.object({ success: z.literal(true), - deployedAt: z.string(), + deployedAt: z.string().nullable().optional(), warnings: z.array(z.string()).optional(), name: z.string().nullable().optional(), description: z.string().nullable().optional(), + activeDeployment: activeDeploymentSummarySchema.nullable().optional(), + latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(), }) export type ActivateDeploymentVersionResponse = z.output< diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 0165c512bd3..239c420431f 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -68,6 +68,35 @@ const mothershipExecuteFileAttachmentSchema = z }) .passthrough() +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]) +) +const mothershipExecuteMcpInputSchema = z.record(z.string(), jsonValueSchema) + +const mothershipExecuteMcpToolSchema = z + .object({ + type: z.literal('mcp'), + usageControl: z.enum(['auto', 'force', 'none']).optional(), + title: z.string().optional(), + schema: mothershipExecuteMcpInputSchema.optional(), + params: z + .object({ + serverId: z.string().min(1), + toolName: z.string().min(1), + serverName: z.string().optional(), + }) + .passthrough(), + }) + .passthrough() + export const mothershipExecuteBodySchema = z.object({ messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'), responseFormat: z.any().optional(), @@ -83,6 +112,7 @@ export const mothershipExecuteBodySchema = z.object({ * captured contexts must reach the run without a live client. */ contexts: z.array(scheduleContextSchema).optional(), + mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), userMetadata: z @@ -276,6 +306,13 @@ export const forkMothershipChatContract = defineRouteContract({ schema: z.object({ success: z.literal(true), id: z.string(), + /** + * Present (and > 0) when some file blobs could not be byte-copied: the + * new chat exists and its transcript references those copies, but their + * bytes are missing (blob copies are best-effort, post-transaction). + * Callers should surface a warning. + */ + failedFileCopies: z.number().optional(), }), }, }) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 1c447b700f3..a1de918b1df 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -88,6 +88,7 @@ const oauthTokenResponseSchema = z.object({ instanceUrl: z.string().optional(), cloudId: z.string().optional(), domain: z.string().optional(), + authStyle: z.enum(['x-api-token']).optional(), }) export const oauthTokenGetContract = defineRouteContract({ @@ -251,6 +252,7 @@ export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), workspaceId: workspaceIdSchema, callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), }) export const authorizeOAuth2Contract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 6acf45a4238..1c6e1e28eb3 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { PII_LANGUAGE_CODES } from '@/lib/guardrails/pii-entities' +import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities' export const unknownRecordSchema = z.record(z.string(), z.unknown()) @@ -134,12 +134,31 @@ export const piiStagePolicySchema = z export type PiiStagePolicy = z.output -/** The three redaction stages, each independently configured. */ -export const piiStagesSchema = z.object({ - input: piiStagePolicySchema, - blockOutputs: piiStagePolicySchema, - logs: piiStagePolicySchema, -}) +/** + * The three redaction stages, each independently configured. + * + * Block outputs are regex-only: they run in-flight on Presidio's spaCy-free fast + * path, so the spaCy-NER entities (PERSON/LOCATION/NRP/DATE_TIME) are stripped + * here rather than rejected — a stored rule that still selects NER stays saveable + * (migration-safe), and a blockOutputs stage left empty by the strip is disabled. + */ +export const piiStagesSchema = z + .object({ + input: piiStagePolicySchema, + blockOutputs: piiStagePolicySchema, + logs: piiStagePolicySchema, + }) + .transform((stages) => { + const entityTypes = stripNerEntities(stages.blockOutputs.entityTypes) + return { + ...stages, + blockOutputs: { + ...stages.blockOutputs, + entityTypes, + enabled: stages.blockOutputs.enabled && entityTypes.length > 0, + }, + } + }) export type PiiStages = z.output diff --git a/apps/sim/lib/api/contracts/selectors/clickup.ts b/apps/sim/lib/api/contracts/selectors/clickup.ts new file mode 100644 index 00000000000..9c2577f95f1 --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/clickup.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + credentialWorkflowBodySchema, + definePostSelector, + idNameSchema, + optionalString, +} from '@/lib/api/contracts/selectors/shared' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' + +export const clickupWorkspacesBodySchema = credentialWorkflowBodySchema + +export const clickupSpacesBodySchema = credentialWorkflowBodySchema.extend({ + teamId: z.string().min(1, 'Workspace (team) ID is required'), +}) + +export const clickupFoldersBodySchema = credentialWorkflowBodySchema.extend({ + spaceId: z.string().min(1, 'Space ID is required'), +}) + +/** + * ClickUp lists live either inside a folder or directly in a space + * (folderless). The route dispatches on whichever ID is provided, preferring + * the folder when both are present. + */ +export const clickupListsBodySchema = credentialWorkflowBodySchema + .extend({ + folderId: optionalString, + spaceId: optionalString, + }) + .superRefine((body, ctx) => { + if (!body.folderId?.trim() && !body.spaceId?.trim()) { + ctx.addIssue({ + code: 'custom', + path: ['folderId'], + message: 'Either folderId or spaceId is required', + }) + } + }) + +export const clickupWorkspacesSelectorContract = definePostSelector( + '/api/tools/clickup/workspaces', + clickupWorkspacesBodySchema, + z.object({ workspaces: z.array(idNameSchema) }) +) + +export const clickupSpacesSelectorContract = definePostSelector( + '/api/tools/clickup/spaces', + clickupSpacesBodySchema, + z.object({ spaces: z.array(idNameSchema) }) +) + +export const clickupFoldersSelectorContract = definePostSelector( + '/api/tools/clickup/folders', + clickupFoldersBodySchema, + z.object({ folders: z.array(idNameSchema) }) +) + +export const clickupListsSelectorContract = definePostSelector( + '/api/tools/clickup/lists', + clickupListsBodySchema, + z.object({ lists: z.array(idNameSchema) }) +) + +export type ClickupWorkspacesSelectorResponse = ContractJsonResponse< + typeof clickupWorkspacesSelectorContract +> +export type ClickupSpacesSelectorResponse = ContractJsonResponse< + typeof clickupSpacesSelectorContract +> +export type ClickupFoldersSelectorResponse = ContractJsonResponse< + typeof clickupFoldersSelectorContract +> +export type ClickupListsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/index.ts b/apps/sim/lib/api/contracts/selectors/index.ts index 8bab27ce372..7c4ac0d5005 100644 --- a/apps/sim/lib/api/contracts/selectors/index.ts +++ b/apps/sim/lib/api/contracts/selectors/index.ts @@ -15,6 +15,12 @@ import { calcomEventTypesSelectorContract, calcomSchedulesSelectorContract, } from '@/lib/api/contracts/selectors/calcom' +import { + clickupFoldersSelectorContract, + clickupListsSelectorContract, + clickupSpacesSelectorContract, + clickupWorkspacesSelectorContract, +} from '@/lib/api/contracts/selectors/clickup' import { cloudwatchLogGroupsSelectorContract, cloudwatchLogStreamsSelectorContract, @@ -107,6 +113,7 @@ export * from '@/lib/api/contracts/selectors/asana' export * from '@/lib/api/contracts/selectors/attio' export * from '@/lib/api/contracts/selectors/bigquery' export * from '@/lib/api/contracts/selectors/calcom' +export * from '@/lib/api/contracts/selectors/clickup' export * from '@/lib/api/contracts/selectors/cloudwatch' export * from '@/lib/api/contracts/selectors/confluence' export * from '@/lib/api/contracts/selectors/google' @@ -137,6 +144,10 @@ export const selectorContractsByPath = { '/api/tools/google_bigquery/tables': bigQueryTablesSelectorContract, '/api/tools/calcom/event-types': calcomEventTypesSelectorContract, '/api/tools/calcom/schedules': calcomSchedulesSelectorContract, + '/api/tools/clickup/workspaces': clickupWorkspacesSelectorContract, + '/api/tools/clickup/spaces': clickupSpacesSelectorContract, + '/api/tools/clickup/folders': clickupFoldersSelectorContract, + '/api/tools/clickup/lists': clickupListsSelectorContract, '/api/tools/confluence/selector-spaces': confluenceSpacesSelectorContract, '/api/tools/jsm/selector-servicedesks': jsmServiceDesksSelectorContract, '/api/tools/jsm/selector-requesttypes': jsmRequestTypesSelectorContract, diff --git a/apps/sim/lib/api/contracts/tools/clickup.ts b/apps/sim/lib/api/contracts/tools/clickup.ts new file mode 100644 index 00000000000..cebc498265f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/clickup.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' + +export const clickupUploadAttachmentBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + taskId: z.string().min(1, 'Task ID is required'), + file: FileInputSchema, +}) + +const clickupAttachmentSchema = z.object({ + id: z.string(), + version: z.string().nullable(), + title: z.string().nullable(), + extension: z.string().nullable(), + url: z.string().nullable(), + date: z.number().nullable(), + thumbnailSmall: z.string().nullable(), + thumbnailLarge: z.string().nullable(), +}) + +const clickupUserFileSchema = z + .object({ + id: z.string(), + name: z.string(), + url: z.string(), + size: z.number(), + type: z.string(), + key: z.string(), + }) + .passthrough() + +const clickupUploadAttachmentResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + attachment: clickupAttachmentSchema, + files: z.array(clickupUserFileSchema), + }), +}) + +export const clickupUploadAttachmentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickup/upload-attachment', + body: clickupUploadAttachmentBodySchema, + response: { mode: 'json', schema: clickupUploadAttachmentResponseSchema }, +}) + +export type ClickUpUploadAttachmentBody = ContractBody +export type ClickUpUploadAttachmentApiResponse = ContractJsonResponse< + typeof clickupUploadAttachmentContract +> diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index 3270a34ef99..c1bfb0ccdbe 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -2,6 +2,7 @@ export * from './a2a' export * from './agiloft' export * from './asana' export * from './brex' +export * from './clickup' export * from './communication' export * from './crowdstrike' export * from './cursor' diff --git a/apps/sim/lib/api/contracts/tools/pipedrive.ts b/apps/sim/lib/api/contracts/tools/pipedrive.ts index 454a6a77099..d1d33f86067 100644 --- a/apps/sim/lib/api/contracts/tools/pipedrive.ts +++ b/apps/sim/lib/api/contracts/tools/pipedrive.ts @@ -27,6 +27,7 @@ export const pipedriveGetFilesResponseSchema = z.object({ export const pipedriveGetFilesBodySchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), + authStyle: z.enum(['x-api-token']).optional(), sort: z.enum(['id', 'update_time']).optional().nullable(), limit: z.string().optional().nullable(), start: z.string().optional().nullable(), diff --git a/apps/sim/lib/api/contracts/v1/admin/workflows.ts b/apps/sim/lib/api/contracts/v1/admin/workflows.ts index ca279afd942..8ef61531579 100644 --- a/apps/sim/lib/api/contracts/v1/admin/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/admin/workflows.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + activeDeploymentSummarySchema, + deploymentOperationSummarySchema, +} from '@/lib/api/contracts/deployments' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { adminV1ExportFormatQuerySchema, @@ -130,10 +134,12 @@ export const adminV1DeploymentVersionSchema = z.object({ }) export const adminV1DeployResultSchema = z.object({ - isDeployed: z.literal(true), - version: z.number(), - deployedAt: z.string(), + isDeployed: z.boolean(), + version: z.number().nullable(), + deployedAt: z.string().nullable(), warnings: z.array(z.string()).optional(), + activeDeployment: activeDeploymentSummarySchema.nullable().optional(), + latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(), }) export const adminV1UndeployResultSchema = z.object({ @@ -171,8 +177,10 @@ const adminV1WorkflowVersionsResultSchema = z.object({ const adminV1ActivateWorkflowVersionResultSchema = z.object({ success: z.literal(true), version: z.number(), - deployedAt: z.string(), + deployedAt: z.string().nullable(), warnings: z.array(z.string()).optional(), + activeDeployment: activeDeploymentSummarySchema.nullable().optional(), + latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(), }) export const adminV1ListWorkflowsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 5d46dcae135..d0c1469e189 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { deploymentVersionMetadataFieldsSchema } from '@/lib/api/contracts/deployments' +import { + activeDeploymentSummarySchema, + deploymentOperationSummarySchema, + deploymentVersionMetadataFieldsSchema, +} from '@/lib/api/contracts/deployments' import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' @@ -93,13 +97,25 @@ const v1DeploymentStateSchema = z.object({ warnings: z.array(z.string()), }) -export const v1DeployWorkflowDataSchema = v1DeploymentStateSchema.extend({ +/** + * Deploy/rollback admit asynchronously: HTTP success means the attempt was + * accepted, while `isDeployed` reflects whether a version is actually live. + * `latestDeploymentAttempt` carries the lifecycle status + * (preparing/activating/active/failed/superseded) so API consumers can poll + * to a terminal state instead of guessing from `isDeployed` alone. + */ +const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({ + activeDeployment: activeDeploymentSummarySchema.nullable(), + latestDeploymentAttempt: deploymentOperationSummarySchema.nullable(), +}) + +export const v1DeployWorkflowDataSchema = v1DeploymentLifecycleSchema.extend({ version: z.number().optional(), }) export type V1DeployWorkflowData = z.output -export const v1RollbackWorkflowDataSchema = v1DeploymentStateSchema.extend({ +export const v1RollbackWorkflowDataSchema = v1DeploymentLifecycleSchema.extend({ version: z.number(), }) diff --git a/apps/sim/lib/api/contracts/workflows.test.ts b/apps/sim/lib/api/contracts/workflows.test.ts index d70ec31fd5d..58857575331 100644 --- a/apps/sim/lib/api/contracts/workflows.test.ts +++ b/apps/sim/lib/api/contracts/workflows.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' +import { + executeWorkflowBodySchema, + updateWorkflowBodySchema, + workflowListItemSchema, +} from '@/lib/api/contracts/workflows' describe('workflow contracts', () => { it('normalizes null React Flow edge handles in execution overrides', () => { @@ -43,4 +47,25 @@ describe('workflow contracts', () => { expect(parsed.workflowStateOverride?.edges[0].sourceHandle).toBeUndefined() expect(parsed.workflowStateOverride?.edges[0].targetHandle).toBeUndefined() }) + + it('updateWorkflowBodySchema accepts forkSyncExcluded and leaves it optional', () => { + expect(updateWorkflowBodySchema.parse({ forkSyncExcluded: true }).forkSyncExcluded).toBe(true) + expect(updateWorkflowBodySchema.parse({}).forkSyncExcluded).toBeUndefined() + }) + + it('workflowListItemSchema defaults an absent forkSyncExcluded to false (old-server tolerance)', () => { + const item = workflowListItemSchema.parse({ + id: 'wf-1', + name: 'Alpha', + description: null, + workspaceId: 'ws-1', + folderId: null, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + archivedAt: null, + locked: false, + }) + expect(item.forkSyncExcluded).toBe(false) + }) }) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 016b0b94d9c..6920db4f5d5 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -222,6 +222,8 @@ export const workflowListItemSchema = z.object({ updatedAt: z.string(), archivedAt: z.string().nullable(), locked: z.boolean(), + /** Defaulted so a new client tolerates an old server's response during rollout. */ + forkSyncExcluded: z.boolean().default(false), isDeployed: z.boolean().optional(), }) @@ -281,6 +283,7 @@ export const updateWorkflowBodySchema = z.object({ folderId: z.string().nullable().optional(), sortOrder: z.number().int().min(0).optional(), locked: z.boolean().optional(), + forkSyncExcluded: z.boolean().optional(), }) export type UpdateWorkflowBody = z.input @@ -345,6 +348,8 @@ export const executeWorkflowBodySchema = z.object({ includeFileBase64: z.boolean().optional().default(true), base64MaxBytes: z.number().int().positive().optional(), workflowStateOverride: workflowStateSchema.optional(), + /** Internal MCP bridge pin for calls admitted before a deployment cutover. */ + deploymentVersionId: z.string().min(1).optional(), executionId: z.unknown().optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/workspace-fork.test.ts b/apps/sim/lib/api/contracts/workspace-fork.test.ts index 0441151bc54..9a72c30fc0f 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.test.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.test.ts @@ -6,7 +6,9 @@ import { forkLineageChildSchema, forkLineageNodeSchema, forkMappableResourceTypeSchema, + getForkDiffContract, getWorkspaceBackgroundWorkQuerySchema, + updateForkExcludedWorkflowsBodySchema, updateForkMappingBodySchema, } from '@/lib/api/contracts/workspace-fork' @@ -129,3 +131,71 @@ describe('updateForkMappingBodySchema', () => { } }) }) + +describe('updateForkExcludedWorkflowsBodySchema', () => { + it('accepts a batch of workflow ids with the exclusion flag', () => { + const parsed = updateForkExcludedWorkflowsBodySchema.parse({ + workflowIds: ['wf-1', 'wf-2'], + forkSyncExcluded: true, + }) + expect(parsed).toEqual({ workflowIds: ['wf-1', 'wf-2'], forkSyncExcluded: true }) + }) + + it('rejects an empty id list, empty ids, and oversized batches', () => { + expect( + updateForkExcludedWorkflowsBodySchema.safeParse({ workflowIds: [], forkSyncExcluded: true }) + .success + ).toBe(false) + expect( + updateForkExcludedWorkflowsBodySchema.safeParse({ workflowIds: [''], forkSyncExcluded: true }) + .success + ).toBe(false) + expect( + updateForkExcludedWorkflowsBodySchema.safeParse({ + workflowIds: Array.from({ length: 1001 }, (_, index) => `wf-${index}`), + forkSyncExcluded: false, + }).success + ).toBe(false) + }) + + it('requires the forkSyncExcluded flag', () => { + expect(updateForkExcludedWorkflowsBodySchema.safeParse({ workflowIds: ['wf-1'] }).success).toBe( + false + ) + }) +}) + +describe('getForkDiffContract response excluded-workflow lists', () => { + const baseDiffResponse = { + sourceWorkspaceId: 'ws-src', + targetWorkspaceId: 'ws-tgt', + willUpdate: 0, + willCreate: 0, + willArchive: 0, + workflows: [], + unmappedRequired: [], + unmappedOptional: [], + mcpReauthServerIds: [], + inlineSecretSources: [], + dependentReconfigs: [], + resourceUsages: [], + copyableUnmapped: [], + clearedRefs: [], + } + + it('defaults absent lists to empty (old-server tolerance)', () => { + const parsed = getForkDiffContract.response.schema.parse(baseDiffResponse) + expect(parsed.excludedSourceWorkflows).toEqual([]) + expect(parsed.excludedTargetWorkflows).toEqual([]) + }) + + it('carries the lists when present', () => { + const parsed = getForkDiffContract.response.schema.parse({ + ...baseDiffResponse, + excludedSourceWorkflows: ['Scratch agent'], + excludedTargetWorkflows: ['Prod hotfix'], + }) + expect(parsed.excludedSourceWorkflows).toEqual(['Scratch agent']) + expect(parsed.excludedTargetWorkflows).toEqual(['Prod hotfix']) + }) +}) diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 04736f9a6b8..6a62bb4c15d 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -501,6 +501,16 @@ export const getForkDiffContract = defineRouteContract({ willArchive: z.number().int(), /** Per-workflow change list for the sync preview. */ workflows: z.array(forkWorkflowChangeSchema), + /** + * Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. + * Defaulted so a new client tolerates an old server's response during rollout. + */ + excludedSourceWorkflows: z.array(z.string()).default([]), + /** + * Names of mapped TARGET workflows marked "Exclude from sync" - the sync + * neither replaces nor archives them. Defaulted for rollout tolerance. + */ + excludedTargetWorkflows: z.array(z.string()).default([]), unmappedRequired: z.array(forkUnmappedReferenceSchema), unmappedOptional: z.array(forkUnmappedReferenceSchema), /** Source MCP server ids that use OAuth and need re-authorization in the target. */ @@ -732,6 +742,11 @@ export const rollbackForkContract = defineRouteContract({ unarchived: z.number().int(), /** Snapshot workflows that no longer exist and couldn't be reactivated. */ skipped: z.number().int(), + /** + * Workflows whose restored version has not finished its activation + * cutover; the undo point is preserved so the rollback can be re-run. + */ + pendingActivations: z.array(z.string()), }), }, }) @@ -772,3 +787,30 @@ export const unlinkForkContract = defineRouteContract({ }) export type UnlinkForkBody = z.input export type UnlinkForkResponse = z.output + +export const updateForkExcludedWorkflowsBodySchema = z.object({ + /** Workflows to mark or unmark; ids outside the workspace are ignored. */ + workflowIds: z + .array(z.string().min(1, 'workflowIds entries cannot be empty')) + .min(1, 'workflowIds cannot be empty') + .max(1000, 'Cannot update more than 1000 workflows at once'), + /** True marks the workflows "Exclude from sync"; false includes them again. */ + forkSyncExcluded: z.boolean(), +}) +export const updateForkExcludedWorkflowsContract = defineRouteContract({ + method: 'PUT', + path: '/api/workspaces/[id]/fork/excluded-workflows', + params: workspaceIdParamsSchema, + body: updateForkExcludedWorkflowsBodySchema, + response: { + mode: 'json', + schema: z.object({ + /** Number of workflows actually updated (ids outside the workspace are skipped). */ + updated: z.number().int(), + }), + }, +}) +export type UpdateForkExcludedWorkflowsBody = z.input +export type UpdateForkExcludedWorkflowsResponse = z.output< + typeof updateForkExcludedWorkflowsContract.response.schema +> diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 61a104eccf2..d5cdc948345 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -8,9 +8,9 @@ import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { betterAuth, type User } from 'better-auth' +import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware } from 'better-auth/api' +import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -34,8 +34,13 @@ import { import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' +import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' import { sendPlanWelcomeEmail } from '@/lib/billing' -import { authorizeSubscriptionReference } from '@/lib/billing/authorization' +import { + assertPersonalCheckoutAllowed, + authorizeSubscriptionReference, + isPersonalCheckoutRequest, +} from '@/lib/billing/authorization' import { getOrganizationIdForSubscriptionReference, syncSubscriptionPlan, @@ -46,7 +51,8 @@ import { ensureOrganizationForTeamSubscription, syncSubscriptionUsageLimits, } from '@/lib/billing/organization' -import { isTeam } from '@/lib/billing/plan-helpers' +import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership' +import { isPro, isTeam } from '@/lib/billing/plan-helpers' import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans' import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout' @@ -58,7 +64,6 @@ import { handleInvoicePaymentSucceeded, } from '@/lib/billing/webhooks/invoices' import { - handleOrganizationPlanDowngrade, handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' @@ -193,10 +198,13 @@ export const auth = betterAuth({ ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), ...additionalTrustedOrigins, ].filter(Boolean), - database: drizzleAdapter(db, { - provider: 'pg', - schema, - }), + database: (options: BetterAuthOptions) => + guardSubscriptionPlanWrites( + drizzleAdapter(db, { + provider: 'pg', + schema, + })(options) + ), session: { cookieCache: { enabled: true, @@ -903,6 +911,21 @@ export const auth = betterAuth({ } } + /** + * Personal checkout guard. The Stripe plugin's `authorizeReference` + * only runs for organization references (it skips references equal to + * the session user), so duplicate-coverage enforcement for personal + * checkouts lives here: a member of an org with an entitled paid + * subscription must not buy a personal plan on top of it. + */ + if (isBillingEnabled && ctx.path === '/subscription/upgrade') { + const session = await getSessionFromCtx(ctx) + const sessionUserId = session?.user?.id + if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) { + await assertPersonalCheckoutAllowed(sessionUserId) + } + } + return }), }, @@ -2348,6 +2371,55 @@ export const auth = betterAuth({ }, }, + { + providerId: 'clickup', + clientId: env.CLICKUP_CLIENT_ID as string, + clientSecret: env.CLICKUP_CLIENT_SECRET as string, + authorizationUrl: 'https://app.clickup.com/api', + tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', + scopes: getCanonicalScopesForProvider('clickup'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.clickup.com/api/v2/user', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching ClickUp user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.user + if (!user?.id) return null + + const now = new Date() + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.username || 'ClickUp User', + email: user.email || `${user.id}@clickup.user`, + emailVerified: !!user.email, + createdAt: now, + updatedAt: now, + image: user.profilePicture || undefined, + } + } catch (error) { + logger.error('Error in ClickUp getUserInfo:', { error }) + return null + } + }, + }, + { providerId: 'linear', clientId: env.LINEAR_CLIENT_ID as string, @@ -3091,8 +3163,21 @@ export const auth = betterAuth({ subscription: { enabled: true, plans: getPlans(), - authorizeReference: async ({ user, referenceId, action }) => { - return await authorizeSubscriptionReference(user.id, referenceId, action) + authorizeReference: async ({ user, referenceId, action }, ctx) => { + const body: unknown = ctx?.body + const requestedPlan = + typeof body === 'object' && + body !== null && + 'plan' in body && + typeof body.plan === 'string' + ? body.plan + : undefined + return await authorizeSubscriptionReference( + user.id, + referenceId, + action, + requestedPlan + ) }, getCheckoutSessionParams: async () => ({ params: { allow_promotion_codes: true }, @@ -3126,11 +3211,16 @@ export const auth = betterAuth({ ) } - await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const syncedPlan = await syncSubscriptionPlan( + subscription.id, + subscription.plan, + planFromStripe, + subscription.referenceId + ) const subscriptionForOrg = { ...subscription, - plan: planFromStripe ?? subscription.plan, + plan: syncedPlan ?? subscription.plan, enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null, } @@ -3153,7 +3243,29 @@ export const auth = betterAuth({ throw orgError } - await handleSubscriptionCreated(resolvedSubscription, event.id) + /** + * Transactional fence behind the personal-checkout admission + * guard: if the user joined a paid organization while their + * checkout was in flight, pause the fresh personal Pro at + * period end (same state a paid-org joiner's personal Pro + * enters; restored automatically if they leave the org). + * + * Runs BEFORE the free→paid transition handling: a personal + * subscription born covered is not a free→paid transition — + * the org plan keeps governing the user — so the usage reset + * (which would wipe org-attributed current-period usage) and + * its instrumentation must not run. Gated on `covered`, not + * `paused`, so event retries decide identically even when the + * join path already paused the subscription. + */ + const coveredByOrganization = isPro(resolvedSubscription.plan) + ? (await pauseProSubscriptionForOrgCoverage(resolvedSubscription.referenceId)) + .covered + : false + + if (!coveredByOrganization) { + await handleSubscriptionCreated(resolvedSubscription, event.id) + } await syncSubscriptionUsageLimits(resolvedSubscription) @@ -3189,8 +3301,6 @@ export const auth = betterAuth({ const isUpgradeToTeam = isTeamPlan && !isTeam(subscription.plan) && referenceOrganizationId == null - const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan - logger.info('[onSubscriptionUpdate] Subscription updated', { subscriptionId: subscription.id, status: subscription.status, @@ -3209,11 +3319,24 @@ export const auth = betterAuth({ ) } - await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const syncedPlan = await syncSubscriptionPlan( + subscription.id, + subscription.plan, + planFromStripe, + subscription.referenceId + ) + + /** + * All downstream processing keys off the plan the DB actually + * holds after the sync — a plan write refused by the org/plan + * invariant must not leak the rejected Stripe plan into org + * resolution, seat sync, or usage limits. + */ + const effectivePlanForTeamFeatures = syncedPlan ?? subscription.plan const subscriptionForOrg = { ...subscription, - plan: planFromStripe ?? subscription.plan, + plan: effectivePlanForTeamFeatures, enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null, } @@ -3284,16 +3407,6 @@ export const auth = betterAuth({ error, }) } - } else { - await handleOrganizationPlanDowngrade( - { - id: resolvedSubscription.id, - plan: effectivePlanForTeamFeatures ?? null, - referenceId: resolvedSubscription.referenceId, - status: resolvedSubscription.status ?? null, - }, - event.id - ) } await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month') diff --git a/apps/sim/lib/auth/session-response.ts b/apps/sim/lib/auth/session-response.ts index f78675ae178..1a450435f03 100644 --- a/apps/sim/lib/auth/session-response.ts +++ b/apps/sim/lib/auth/session-response.ts @@ -25,6 +25,18 @@ export type AppSession = { } } | null +/** + * Reads the organization plugin's `activeOrganizationId` off a session object + * (server `getSession()` result or client {@link AppSession}). Better Auth's + * inferred server session type does not declare the field, so this is the one + * place the untyped read happens. + */ +export function getActiveOrganizationId(session: unknown): string | null { + if (!isRecordLike(session) || !isRecordLike(session.session)) return null + const value = session.session.activeOrganizationId + return typeof value === 'string' ? value : null +} + interface BetterAuthErrorEnvelope { data: null error: { diff --git a/apps/sim/lib/auth/stripe-adapter-guard.test.ts b/apps/sim/lib/auth/stripe-adapter-guard.test.ts new file mode 100644 index 00000000000..8c8521041ee --- /dev/null +++ b/apps/sim/lib/auth/stripe-adapter-guard.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) + +import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' + +function createBaseAdapter() { + return { + create: vi.fn(async (data: { data: unknown }) => data.data), + update: vi.fn(async (data: { update: unknown }) => data.update), + updateMany: vi.fn(async () => 1), + findOne: vi.fn(), + } +} + +/** + * The guard only relies on create/update/updateMany/findOne; the remaining + * adapter surface passes through the spread untouched. + */ +// double-cast-allowed: test double implements only the adapter subset the guard touches +const asAdapter = (base: ReturnType) => + guardSubscriptionPlanWrites(base as unknown as Parameters[0]) + +const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' } +const WHERE = [{ field: 'id', value: 'sub-1' }] + +describe('guardSubscriptionPlanWrites', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('strips a non-org plan from an update targeting an org-referenced row but keeps the rest', async () => { + const base = createBaseAdapter() + base.findOne.mockResolvedValueOnce(ORG_ROW) + // org existence lookup + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + + const guarded = asAdapter(base) + await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'pro_6000', status: 'active', seats: 2 }, + }) + + expect(base.update).toHaveBeenCalledWith( + expect.objectContaining({ update: { status: 'active', seats: 2 } }) + ) + }) + + it('returns the current row without writing when stripping leaves an empty update', async () => { + const base = createBaseAdapter() + base.findOne.mockResolvedValueOnce(ORG_ROW).mockResolvedValueOnce(ORG_ROW) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + + const guarded = asAdapter(base) + const result = await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'pro_6000' }, + }) + + expect(result).toEqual(ORG_ROW) + expect(base.update).not.toHaveBeenCalled() + }) + + it('strips the plan when the pre-update lookup finds no row (fail closed)', async () => { + const base = createBaseAdapter() + base.findOne.mockResolvedValueOnce(null) + + const guarded = asAdapter(base) + await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'pro_6000', status: 'active' }, + }) + + expect(base.update).toHaveBeenCalledWith( + expect.objectContaining({ update: { status: 'active' } }) + ) + }) + + it('writes nothing when a plan-only update targets no readable row', async () => { + const base = createBaseAdapter() + base.findOne.mockResolvedValueOnce(null) + + const guarded = asAdapter(base) + const result = await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'pro_6000' }, + }) + + expect(result).toBeNull() + expect(base.update).not.toHaveBeenCalled() + }) + + it('passes through non-org plan updates for user-referenced rows', async () => { + const base = createBaseAdapter() + base.findOne.mockResolvedValueOnce({ id: 'sub-2', referenceId: 'user-1', plan: 'pro_6000' }) + // org existence lookup: user id is not an organization + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const guarded = asAdapter(base) + await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'pro_25000', status: 'active' }, + }) + + expect(base.update).toHaveBeenCalledWith( + expect.objectContaining({ update: { plan: 'pro_25000', status: 'active' } }) + ) + }) + + it('passes through org-plan updates without any row lookup', async () => { + const base = createBaseAdapter() + + const guarded = asAdapter(base) + await guarded.update({ + model: 'subscription', + where: WHERE as never, + update: { plan: 'team_25000', seats: 5 }, + }) + + expect(base.findOne).not.toHaveBeenCalled() + expect(base.update).toHaveBeenCalledWith( + expect.objectContaining({ update: { plan: 'team_25000', seats: 5 } }) + ) + }) + + it('passes through updates on other models untouched', async () => { + const base = createBaseAdapter() + + const guarded = asAdapter(base) + await guarded.update({ + model: 'user', + where: WHERE as never, + update: { plan: 'pro_6000' }, + }) + + expect(base.findOne).not.toHaveBeenCalled() + expect(base.update).toHaveBeenCalled() + }) + + it('rejects creating an org-referenced subscription with a non-org plan', async () => { + const base = createBaseAdapter() + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + + const guarded = asAdapter(base) + await expect( + guarded.create({ + model: 'subscription', + data: { plan: 'pro_6000', referenceId: 'org-1' } as never, + }) + ).rejects.toThrow(/must hold a Team or Enterprise plan/) + + expect(base.create).not.toHaveBeenCalled() + }) + + it('allows creating a personal pro subscription', async () => { + const base = createBaseAdapter() + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const guarded = asAdapter(base) + await guarded.create({ + model: 'subscription', + data: { plan: 'pro_6000', referenceId: 'user-1' } as never, + }) + + expect(base.create).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/auth/stripe-adapter-guard.ts b/apps/sim/lib/auth/stripe-adapter-guard.ts new file mode 100644 index 00000000000..194af9cd6f3 --- /dev/null +++ b/apps/sim/lib/auth/stripe-adapter-guard.ts @@ -0,0 +1,185 @@ +import { db } from '@sim/db' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import type { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { eq } from 'drizzle-orm' +import { isOrgPlan } from '@/lib/billing/plan-helpers' + +const logger = createLogger('StripeAdapterGuard') + +type BetterAuthAdapter = ReturnType> + +type SubscriptionWriteSurface = Pick< + BetterAuthAdapter, + 'create' | 'update' | 'updateMany' | 'findOne' | 'findMany' +> + +/** + * The Better Auth Stripe plugin persists webhook state through the raw + * database adapter BEFORE invoking our subscription callbacks — including the + * `plan` column resolved from the Stripe price. That makes the adapter the + * only in-process seam that can enforce the billing invariant that + * organization-referenced subscriptions hold Team/Enterprise plans: by the + * time `syncSubscriptionPlan` runs in a callback, the plugin's write has + * already landed. + * + * Checkout admission blocks user-driven violations; this guard blocks the + * remaining vector — an operator swapping an org subscription onto a personal + * price in the Stripe dashboard. It never repairs state: an invalid `plan` + * write is refused (update: the field is stripped so status/period/seat sync + * still lands; create: the whole insert is rejected) and an error is logged + * so operators fix the price in Stripe. + * + * Transactions are wrapped recursively so writes inside + * `adapter.transaction(...)` callbacks go through the same guard. + */ +export function guardSubscriptionPlanWrites(adapter: BetterAuthAdapter): BetterAuthAdapter { + const guarded: BetterAuthAdapter = { + ...adapter, + ...guardWriteSurface(adapter), + } + + const transaction = adapter.transaction + if (typeof transaction === 'function') { + guarded.transaction = (callback) => + transaction((trx) => callback({ ...trx, ...guardWriteSurface(trx) })) + } + + return guarded +} + +function guardWriteSurface( + adapter: TAdapter +): SubscriptionWriteSurface { + return { + findOne: adapter.findOne, + findMany: adapter.findMany, + create: async (data) => { + if (data.model === 'subscription') { + const values = data.data as Record + const plan = values.plan + const referenceId = values.referenceId + if ( + typeof plan === 'string' && + !isOrgPlan(plan) && + typeof referenceId === 'string' && + (await isOrganizationReference(referenceId)) + ) { + logger.error( + 'Blocked creating an organization-referenced subscription with a non-org plan — fix the plan or reference in Stripe', + { referenceId, rejectedPlan: plan } + ) + throw new Error( + `Organization-referenced subscriptions must hold a Team or Enterprise plan (got '${plan}')` + ) + } + } + return adapter.create(data) + }, + update: async (data) => { + if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) { + const row = await adapter.findOne({ + model: 'subscription', + where: data.where, + }) + const sanitized = await stripPlanWhenOrgReferenced( + row ? [row] : [], + data.update as Record + ) + if (sanitized.blockedAll) return row as never + return adapter.update({ ...data, update: sanitized.update as never }) + } + return adapter.update(data) + }, + updateMany: async (data) => { + if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) { + const rows = await adapter.findMany({ + model: 'subscription', + where: data.where, + }) + const sanitized = await stripPlanWhenOrgReferenced(rows, data.update) + if (sanitized.blockedAll) return 0 + return adapter.updateMany({ ...data, update: sanitized.update }) + } + return adapter.updateMany(data) + }, + } +} + +interface SubscriptionRowSlice { + id: string + referenceId: string + plan: string +} + +function hasNonOrgPlanWrite(update: unknown): boolean { + if (!update || typeof update !== 'object' || !('plan' in update)) return false + const plan = (update as { plan: unknown }).plan + return typeof plan === 'string' && !isOrgPlan(plan) +} + +async function isOrganizationReference(referenceId: string): Promise { + const [referencedOrganization] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, referenceId)) + .limit(1) + return Boolean(referencedOrganization) +} + +interface SanitizedSubscriptionUpdate { + update: Record + /** True when stripping the invalid plan left nothing to write. */ + blockedAll: boolean +} + +/** + * Strip a non-org `plan` (and its derived `limits`) from an update when ANY + * targeted row is organization-referenced. The remaining fields (status, + * periods, seats, cancellation state) are legitimate Stripe state and still + * sync. Evaluating every targeted row keeps multi-row updates safe: a mixed + * personal/org target set must not leak the invalid plan onto the org rows. + * + * Fails closed on an empty target set: a plan we could not verify against a + * readable row never forwards — the row may be mid-creation by a concurrent + * webhook delivery, and in the sequential no-row case the write was a no-op + * anyway. A legitimate user-referenced plan dropped this way re-syncs via + * `syncSubscriptionPlan` in the same webhook callback. + */ +async function stripPlanWhenOrgReferenced( + rows: SubscriptionRowSlice[], + update: Record +): Promise { + if (rows.length === 0) { + logger.warn( + 'Subscription plan write targeted no readable row; stripping the unverifiable plan', + { rejectedPlan: update.plan } + ) + const { plan: _plan, limits: _limits, ...rest } = update + return { update: rest, blockedAll: Object.keys(rest).length === 0 } + } + + let organizationRow: SubscriptionRowSlice | null = null + for (const row of rows) { + if (await isOrganizationReference(row.referenceId)) { + organizationRow = row + break + } + } + if (!organizationRow) { + return { update, blockedAll: false } + } + + logger.error( + 'Blocked writing a non-org plan onto an organization-referenced subscription — fix the price in Stripe', + { + subscriptionId: organizationRow.id, + organizationId: organizationRow.referenceId, + currentPlan: organizationRow.plan, + rejectedPlan: update.plan, + } + ) + + const { plan: _plan, limits: _limits, ...rest } = update + return { update: rest, blockedAll: Object.keys(rest).length === 0 } +} diff --git a/apps/sim/lib/billing/authorization.test.ts b/apps/sim/lib/billing/authorization.test.ts index 6025302fdb8..002bb2a4537 100644 --- a/apps/sim/lib/billing/authorization.test.ts +++ b/apps/sim/lib/billing/authorization.test.ts @@ -3,10 +3,16 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockHasPaidSubscription, mockIsOwnerOrAdmin, mockAssertNoUnresolved } = vi.hoisted(() => ({ +const { + mockHasPaidSubscription, + mockIsOwnerOrAdmin, + mockAssertNoUnresolved, + mockGetOrganizationCoverageForMember, +} = vi.hoisted(() => ({ mockHasPaidSubscription: vi.fn(), mockIsOwnerOrAdmin: vi.fn(), mockAssertNoUnresolved: vi.fn(), + mockGetOrganizationCoverageForMember: vi.fn(), })) vi.mock('@sim/db', () => ({ db: {} })) @@ -14,6 +20,9 @@ vi.mock('@/lib/billing', () => ({ hasPaidSubscription: mockHasPaidSubscription } vi.mock('@/lib/billing/core/organization', () => ({ isOrganizationOwnerOrAdmin: mockIsOwnerOrAdmin, })) +vi.mock('@/lib/billing/core/subscription', () => ({ + getOrganizationCoverageForMember: mockGetOrganizationCoverageForMember, +})) vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: ({ referenceId }: { referenceId: string }, userId: string) => referenceId !== userId, @@ -26,32 +35,127 @@ vi.mock('@/lib/billing/enterprise-outbox', () => { } }) -import { authorizeSubscriptionReference } from '@/lib/billing/authorization' +import { + assertPersonalCheckoutAllowed, + authorizeSubscriptionReference, + isPersonalCheckoutRequest, +} from '@/lib/billing/authorization' import { EnterpriseIssuanceInProgressError } from '@/lib/billing/enterprise-outbox' +describe('isPersonalCheckoutRequest', () => { + it('classifies an explicit self reference as personal regardless of customerType', () => { + expect(isPersonalCheckoutRequest({ referenceId: 'user-1' }, 'user-1')).toBe(true) + expect( + isPersonalCheckoutRequest({ referenceId: 'user-1', customerType: 'organization' }, 'user-1') + ).toBe(true) + }) + + it('classifies an explicit foreign reference as not personal', () => { + expect(isPersonalCheckoutRequest({ referenceId: 'org-1' }, 'user-1')).toBe(false) + }) + + it('defaults to personal without a reference unless customerType selects the organization', () => { + expect(isPersonalCheckoutRequest({}, 'user-1')).toBe(true) + expect(isPersonalCheckoutRequest({ customerType: 'user' }, 'user-1')).toBe(true) + expect(isPersonalCheckoutRequest({ customerType: 'organization' }, 'user-1')).toBe(false) + }) +}) + describe('authorizeSubscriptionReference', () => { beforeEach(() => { vi.clearAllMocks() mockHasPaidSubscription.mockResolvedValue(false) mockAssertNoUnresolved.mockResolvedValue(undefined) mockIsOwnerOrAdmin.mockResolvedValue(true) + mockGetOrganizationCoverageForMember.mockResolvedValue({ status: 'not-covered' }) }) it('blocks an organization checkout while Enterprise issuance is unresolved', async () => { mockAssertNoUnresolved.mockRejectedValueOnce(new EnterpriseIssuanceInProgressError()) await expect( - authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription') - ).resolves.toBe(false) + authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000') + ).rejects.toThrow(/Enterprise plan setup in progress/) expect(mockAssertNoUnresolved).toHaveBeenCalledWith(expect.anything(), 'org-1') expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled() }) it('allows an authorized organization checkout when no paid or reserved entitlement exists', async () => { + await expect( + authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000') + ).resolves.toBe(true) + expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1') + }) + + it('rejects an organization checkout for a pro plan — org references only hold Team/Enterprise', async () => { + await expect( + authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'pro_6000') + ).rejects.toThrow('Organizations can only subscribe to Team or Enterprise plans.') + + expect(mockHasPaidSubscription).not.toHaveBeenCalled() + expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled() + }) + + it('rejects an organization checkout when the plan cannot be determined (fail closed)', async () => { await expect( authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription') + ).rejects.toThrow('Organizations can only subscribe to Team or Enterprise plans.') + }) + + it('blocks an organization checkout when the organization already has an active subscription', async () => { + mockHasPaidSubscription.mockResolvedValueOnce(true) + + await expect( + authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000') + ).rejects.toThrow(/already has an active subscription/) + }) + + it('does not apply checkout-only rules to other billing actions', async () => { + await expect( + authorizeSubscriptionReference('owner-1', 'org-1', 'cancel-subscription') ).resolves.toBe(true) + + expect(mockHasPaidSubscription).not.toHaveBeenCalled() expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1') }) + + it('allows personal references without invoking org checks', async () => { + await expect( + authorizeSubscriptionReference('user-1', 'user-1', 'upgrade-subscription', 'pro_6000') + ).resolves.toBe(true) + + expect(mockGetOrganizationCoverageForMember).not.toHaveBeenCalled() + expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled() + }) +}) + +describe('assertPersonalCheckoutAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOrganizationCoverageForMember.mockResolvedValue({ status: 'not-covered' }) + }) + + it('allows checkout when the user is not covered by any organization', async () => { + await expect(assertPersonalCheckoutAllowed('user-1')).resolves.toBeUndefined() + }) + + it('rejects checkout when an organization subscription already covers the user', async () => { + mockGetOrganizationCoverageForMember.mockResolvedValueOnce({ + status: 'covered', + organizationId: 'org-1', + }) + + await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow( + /already covered by your organization/ + ) + }) + + it('rejects checkout when coverage cannot be verified (fail closed)', async () => { + mockGetOrganizationCoverageForMember.mockResolvedValueOnce({ status: 'unknown' }) + + await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow( + /could not verify your billing status/ + ) + }) }) diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 8354f768f8e..68586a1ff69 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -1,42 +1,127 @@ import { db } from '@sim/db' import { createLogger } from '@sim/logger' +import { APIError } from 'better-auth/api' import { hasPaidSubscription } from '@/lib/billing' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { getOrganizationCoverageForMember } from '@/lib/billing/core/subscription' import { assertNoUnresolvedEnterpriseIssuance, EnterpriseIssuanceInProgressError, } from '@/lib/billing/enterprise-outbox' +import { isOrgPlan } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' const logger = createLogger('BillingAuthorization') +/** + * Classify a `/subscription/upgrade` request as a personal checkout using + * the same reference resolution as the Better Auth Stripe plugin + * (`@better-auth/stripe` 1.6.13): an explicit `referenceId` defines the + * reference (personal iff it is the session user); without one, the + * reference defaults to the user unless `customerType: 'organization'` + * selects the session's active organization. + * + * This mirror exists because the plugin does not expose its resolution and + * skips `authorizeReference` for personal references entirely. Re-verify + * against the plugin's `referenceMiddleware`/`getReferenceId` when + * upgrading better-auth. + */ +export function isPersonalCheckoutRequest( + body: { referenceId?: unknown; customerType?: unknown }, + sessionUserId: string +): boolean { + if (body.referenceId) return body.referenceId === sessionUserId + return body.customerType !== 'organization' +} + +/** + * Guard for personal (user-referenced) checkouts on `/subscription/upgrade`. + * + * A member of an organization with an entitled paid subscription is already + * covered by that org — their usage pools to it and personal Pro + * subscriptions are paused on join — so a personal checkout would bill the + * same human twice. Throws {@link APIError} with a user-facing message; the + * checkout UI surfaces it as-is. + * + * Called from the Better Auth `hooks.before` middleware, NOT from + * `authorizeReference`: the Stripe plugin skips `authorizeReference` + * entirely when the reference is the session user, so this is the only + * enforcement point that personal checkouts actually pass through. + * + * Fails closed: when coverage cannot be determined, the checkout is + * rejected rather than risking a duplicate subscription. + */ +export async function assertPersonalCheckoutAllowed(userId: string): Promise { + const coverage = await getOrganizationCoverageForMember(userId) + + if (coverage.status === 'covered') { + logger.warn( + 'Blocking personal checkout - user is already covered by an organization subscription', + { userId, organizationId: coverage.organizationId } + ) + throw new APIError('FORBIDDEN', { + message: + "You're already covered by your organization's plan, so a personal plan would bill you twice. Manage your plan from the organization's billing settings.", + }) + } + + if (coverage.status === 'unknown') { + logger.error( + 'Blocking personal checkout - could not verify organization coverage; failing closed', + { userId } + ) + throw new APIError('SERVICE_UNAVAILABLE', { + message: 'We could not verify your billing status. Please try again in a moment.', + }) + } +} + /** * Check if a user is authorized to manage billing for a given reference ID. - * Reference ID can be either a user ID (personal subscription) or an - * organization ID (org-scoped subscription — team, enterprise, or a - * `pro_*` plan transferred to an org). + * Only invoked for organization references — the Stripe plugin skips this + * callback when the reference is the session user itself (personal + * checkouts are guarded by {@link assertPersonalCheckoutAllowed} in the + * `hooks.before` middleware instead). * - * This function also performs duplicate subscription validation for - * organizations: - * - Rejects if an organization already has an active subscription (prevents - * duplicates). - * - Personal subscriptions skip this check to allow upgrades. + * For `upgrade-subscription` (checkout) this enforces, each thrown as an + * {@link APIError} with a descriptive message so callers see the real + * reason instead of a generic "Unauthorized": + * - Organizations can only check out Team or Enterprise plans — a `pro_*` + * plan can never become org-referenced. + * - Organizations cannot start a checkout while they already have an + * active subscription (prevents duplicates). + * - Checkout is deferred while an Enterprise issuance is unresolved. */ export async function authorizeSubscriptionReference( userId: string, referenceId: string, - action?: string + action?: string, + plan?: string ): Promise { if (!isOrgScopedSubscription({ referenceId }, userId)) { return true } + if (action === 'upgrade-subscription' && !isOrgPlan(plan)) { + logger.warn('Blocking checkout - organizations can only subscribe to Team or Enterprise', { + userId, + referenceId, + plan: plan ?? null, + }) + throw new APIError('FORBIDDEN', { + message: 'Organizations can only subscribe to Team or Enterprise plans.', + }) + } + if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) { logger.warn('Blocking checkout - active subscription already exists for organization', { userId, referenceId, }) - return false + throw new APIError('CONFLICT', { + message: + 'This organization already has an active subscription. Manage it from the billing settings.', + }) } if (action === 'upgrade-subscription') { @@ -51,7 +136,10 @@ export async function authorizeSubscriptionReference( userId, referenceId, }) - return false + throw new APIError('CONFLICT', { + message: + 'This organization has an Enterprise plan setup in progress. Please try again in a few minutes or contact support.', + }) } } diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index 9c4e1472519..23729afe105 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -181,7 +181,12 @@ export function useSubscriptionUpgrade() { } ) - await betterAuthSubscription.upgrade(finalParams) + const upgradeResult = await betterAuthSubscription.upgrade(finalParams) + if (upgradeResult?.error) { + throw new Error( + upgradeResult.error.message || 'Checkout could not be started. Please try again.' + ) + } if (targetPlan === 'team' && currentSubscriptionRowId && referenceId !== userId) { try { diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 68c78849aa7..8202e809d8d 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -86,8 +86,11 @@ export async function getOrganizationSubscription( /** * Check if a subscription is scoped to an organization by looking up its * `referenceId` in the organization table. This is the authoritative - * answer — the plan name alone is unreliable because `pro_*` plans can be - * attached to organizations (and we should treat them as org-scoped). + * answer — the plan name alone is unreliable because a team plan can be + * transiently user-referenced between checkout and webhook re-homing. + * (The converse cannot happen: org-referenced subscriptions only ever + * hold Team or Enterprise plans, enforced at checkout authorization and + * in the Stripe plan sync.) * * Use this in server contexts (webhooks, jobs) where we only have the * subscription row, not a user perspective. If you do have a user id, diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index ccfceb21889..f5b2c114c08 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -35,8 +35,11 @@ vi.mock('@/lib/billing/core/plan', () => ({ vi.mock('@/lib/billing/plan-helpers', () => ({ getPlanTierCredits: mockGetPlanTierCredits, isEnterprise: vi.fn().mockReturnValue(false), + isOrgPlan: (plan: string | null | undefined) => + plan === 'enterprise' || plan === 'team' || Boolean(plan?.startsWith('team_')), isPro: vi.fn(), isTeam: vi.fn(), + sqlIsPaid: vi.fn(() => ({ type: 'sqlIsPaid' })), })) vi.mock('@/lib/billing/subscriptions/utils', () => ({ @@ -63,10 +66,12 @@ vi.mock('@/lib/core/config/env-flags', () => ({ vi.mock('@/lib/core/utils/urls', () => urlsMock) import { + getOrganizationCoverageForMember, getOrganizationIdForSubscriptionReference, hasPaidSubscription, hasWorkspaceLiveSyncAccess, isWorkspaceOnEnterprisePlan, + syncSubscriptionPlan, } from '@/lib/billing/core/subscription' describe('hasPaidSubscription', () => { @@ -101,6 +106,79 @@ describe('hasPaidSubscription', () => { }) }) +describe('syncSubscriptionPlan', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes the resolved plan for a user-referenced subscription and returns it', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(syncSubscriptionPlan('sub-1', 'pro_6000', 'pro_25000', 'user-1')).resolves.toBe( + 'pro_25000' + ) + expect(dbChainMockFns.update).toHaveBeenCalled() + }) + + it('writes a team plan onto an org-referenced subscription without an org lookup', async () => { + await expect(syncSubscriptionPlan('sub-1', 'pro_6000', 'team_6000', 'org-1')).resolves.toBe( + 'team_6000' + ) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalled() + }) + + it('refuses a pro plan on an org-referenced subscription and returns the current plan', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + + await expect(syncSubscriptionPlan('sub-1', 'team_6000', 'pro_6000', 'org-1')).resolves.toBe( + 'team_6000' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns the current plan when the Stripe plan is unchanged or unresolved', async () => { + await expect(syncSubscriptionPlan('sub-1', 'team_6000', 'team_6000', 'org-1')).resolves.toBe( + 'team_6000' + ) + await expect(syncSubscriptionPlan('sub-1', 'team_6000', null, 'org-1')).resolves.toBe( + 'team_6000' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('getOrganizationCoverageForMember', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reports covered with the organization id when an entitled paid org exists', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ organizationId: 'org-1' }]) + + await expect(getOrganizationCoverageForMember('user-1')).resolves.toEqual({ + status: 'covered', + organizationId: 'org-1', + }) + }) + + it('reports not-covered when the user has no entitled paid org membership', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(getOrganizationCoverageForMember('user-1')).resolves.toEqual({ + status: 'not-covered', + }) + }) + + it('reports unknown on lookup errors so callers fail closed', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable')) + + await expect(getOrganizationCoverageForMember('user-1')).resolves.toEqual({ + status: 'unknown', + }) + }) +}) + describe('getOrganizationIdForSubscriptionReference', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 09e7b47a950..5ab950c0eb8 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -10,9 +10,11 @@ import { } from '@/lib/billing/core/plan' import { getPlanTierCredits, + isOrgPlan, isEnterprise as isPlanEnterprise, isPro as isPlanPro, isTeam as isPlanTeam, + sqlIsPaid, } from '@/lib/billing/plan-helpers' import { checkEnterprisePlan, @@ -87,16 +89,53 @@ export async function writeBillingInterval( /** * Sync the subscription's `plan` column to match Stripe. Closes a gap * where plan changes (Pro → Team upgrades, tier swaps) updated price, - * seats, and referenceId at Stripe but left the DB plan stale. Returns - * `true` if a write was issued, `false` if no change was needed. + * seats, and referenceId at Stripe but left the DB plan stale. + * + * Enforces the billing invariant that organization-referenced + * subscriptions only ever hold Team or Enterprise plans: when Stripe + * resolves to a non-org plan (e.g. a Pro price was manually swapped onto + * an org subscription in the Stripe dashboard), the write is refused and + * an error is logged so operators fix the price in Stripe — the DB row + * never becomes an org-scoped Pro subscription. + * + * Returns the plan the DB row holds after the call. Callers must drive all + * downstream processing (org ensure, seat sync, usage limits) from this + * value — never from the raw Stripe plan — so a refused write cannot leak + * the rejected plan into the rest of the webhook handler. + * + * The organization lookup is inlined rather than delegated to + * `isSubscriptionOrgScoped` because that helper lives in `core/billing.ts`, + * which imports this module — delegating would create an import cycle. */ export async function syncSubscriptionPlan( subscriptionId: string, currentPlan: string | null, - planFromStripe: string | null -): Promise { - if (!planFromStripe) return false - if (currentPlan === planFromStripe) return false + planFromStripe: string | null, + referenceId: string +): Promise { + if (!planFromStripe) return currentPlan + if (currentPlan === planFromStripe) return currentPlan + + if (!isOrgPlan(planFromStripe)) { + const [referencedOrganization] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, referenceId)) + .limit(1) + + if (referencedOrganization) { + logger.error( + 'Refusing to sync a non-org plan onto an organization-referenced subscription — fix the price in Stripe', + { + subscriptionId, + organizationId: referenceId, + currentPlan, + rejectedPlan: planFromStripe, + } + ) + return currentPlan + } + } await db .update(subscription) @@ -109,7 +148,7 @@ export async function syncSubscriptionPlan( newPlan: planFromStripe, }) - return true + return planFromStripe } /** @@ -188,6 +227,47 @@ export async function hasPaidSubscription( } } +export type OrganizationCoverageResult = + | { status: 'covered'; organizationId: string } + | { status: 'not-covered' } + | { status: 'unknown' } + +/** + * Check whether an organization already covers this user with an entitled + * paid subscription (the user is a member of the org, any role). + * + * Used to block redundant personal checkouts: a member of a paid org has + * their usage pooled to the org (personal Pro subscriptions are paused on + * join), so buying a personal plan would double-bill the same human. + * + * Returns `'unknown'` on error so callers can fail closed (block checkout + * rather than risk a duplicate subscription) with accurate messaging. + */ +export async function getOrganizationCoverageForMember( + userId: string +): Promise { + try { + const [row] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .innerJoin(subscription, eq(subscription.referenceId, member.organizationId)) + .where( + and( + eq(member.userId, userId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES), + sqlIsPaid(subscription.plan) + ) + ) + .limit(1) + + if (row) return { status: 'covered', organizationId: row.organizationId } + return { status: 'not-covered' } + } catch (error) { + logger.error('Error checking organization coverage for member', { error, userId }) + return { status: 'unknown' } + } +} + export async function getOrganizationIdForSubscriptionReference( referenceId: string ): Promise { diff --git a/apps/sim/lib/billing/organization.test.ts b/apps/sim/lib/billing/organization.test.ts index 8e2a566741b..00709265f20 100644 --- a/apps/sim/lib/billing/organization.test.ts +++ b/apps/sim/lib/billing/organization.test.ts @@ -12,6 +12,7 @@ const { mockAcquireOrganizationMutationLock, mockAssertNoCompetingEnterpriseIssuance, mockGetOrganizationIdForSubscriptionReference, + mockIsSubscriptionOrgScoped, } = vi.hoisted(() => ({ mockCreateOrganizationWithOwner: vi.fn(), mockGetPlanPricing: vi.fn(), @@ -20,12 +21,14 @@ const { mockAcquireOrganizationMutationLock: vi.fn(), mockAssertNoCompetingEnterpriseIssuance: vi.fn(), mockGetOrganizationIdForSubscriptionReference: vi.fn(), + mockIsSubscriptionOrgScoped: vi.fn(), })) vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/billing/core/billing', () => ({ getPlanPricing: mockGetPlanPricing, + isSubscriptionOrgScoped: mockIsSubscriptionOrgScoped, })) vi.mock('@/lib/billing/core/subscription', () => ({ @@ -75,8 +78,10 @@ function queueWhereResponses(responses: unknown[][]) { const result = queue.shift() ?? [] const thenable = Promise.resolve(result) as Promise & { limit: ReturnType + for: ReturnType } thenable.limit = vi.fn(() => Promise.resolve(result)) + thenable.for = vi.fn(() => Promise.resolve(result)) return thenable as ReturnType }) } @@ -85,11 +90,11 @@ describe('ensureOrganizationForTeamSubscription', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockGetOrganizationIdForSubscriptionReference.mockResolvedValue(null) + mockIsSubscriptionOrgScoped.mockResolvedValue(false) }) - it('treats existing legacy organization ids as organization references', async () => { - mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('legacy-org-id') + it('treats existing organization references as already homed and takes no write', async () => { + mockIsSubscriptionOrgScoped.mockResolvedValueOnce(true) const result = await ensureOrganizationForTeamSubscription({ id: 'sub-1', @@ -108,6 +113,7 @@ describe('ensureOrganizationForTeamSubscription', () => { }) expect(mockCreateOrganizationWithOwner).not.toHaveBeenCalled() expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockAcquireOrganizationMutationLock).toHaveBeenCalledWith( expect.anything(), 'legacy-org-id' @@ -120,7 +126,7 @@ describe('ensureOrganizationForTeamSubscription', () => { }) it('allows the authoritative Enterprise webhook to apply its own unresolved issuance', async () => { - mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('org-enterprise') + mockIsSubscriptionOrgScoped.mockResolvedValueOnce(true) const result = await ensureOrganizationForTeamSubscription({ id: 'sub-enterprise', @@ -139,6 +145,39 @@ describe('ensureOrganizationForTeamSubscription', () => { ) }) + it('transfers a user-referenced team subscription onto the org the user administers', async () => { + mockIsSubscriptionOrgScoped.mockResolvedValueOnce(false) + queueWhereResponses([ + // membership lookup: user owns an org + [{ id: 'member-1', organizationId: 'org-owned', role: 'owner' }], + // locked membership re-read inside the transfer transaction + [{ organizationId: 'org-owned', role: 'owner' }], + // locked subscription re-read inside the transfer transaction + [{ id: 'sub-1', referenceId: 'user-1', plan: 'team' }], + // locked organization re-read + [{ id: 'org-owned' }], + // duplicate check: org has no entitled subscription + [], + ]) + + const result = await ensureOrganizationForTeamSubscription({ + id: 'sub-1', + plan: 'team', + referenceId: 'user-1', + status: 'active', + seats: 2, + }) + + expect(result.referenceId).toBe('org-owned') + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({ + ownerUserId: 'user-1', + organizationId: 'org-owned', + externalMemberPolicy: 'keep-external', + }) + expect(mockCreateOrganizationWithOwner).not.toHaveBeenCalled() + }) + it('keeps org creation, subscription transfer, and workspace attachment on the caller transaction', async () => { queueWhereResponses([[], [], [{ name: 'Owner', email: 'owner@example.com' }]]) mockAttachOwnedWorkspacesToOrganizationTx.mockRejectedValueOnce( diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index 56a720708f5..a7af195844b 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { and, eq, inArray, ne, sql } from 'drizzle-orm' -import { getPlanPricing } from '@/lib/billing/core/billing' +import { getPlanPricing, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { getOrganizationIdForSubscriptionReference } from '@/lib/billing/core/subscription' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { assertNoCompetingEnterpriseIssuance } from '@/lib/billing/enterprise-outbox' @@ -108,25 +108,26 @@ export async function ensureOrganizationForTeamSubscription( return subscription } - const referencedOrganizationId = await getOrganizationIdForSubscriptionReference( - subscription.referenceId - ) - - if (referencedOrganizationId) { + if (await isSubscriptionOrgScoped(subscription)) { await db.transaction(async (tx) => { - await acquireOrganizationMutationLock(tx, referencedOrganizationId) + await acquireOrganizationMutationLock(tx, subscription.referenceId) await assertNoCompetingEnterpriseIssuance( tx, - referencedOrganizationId, + subscription.referenceId, subscription.enterpriseOperationId ?? null ) }) - return { - ...subscription, - referenceId: referencedOrganizationId, - } + return subscription } + /** + * The subscription references a user. Team/Enterprise subscriptions must be + * org-referenced, so fall through to the membership resolution below: it + * transfers the row onto the org the user administers (with duplicate + * checks under the org mutation lock) or creates a new organization. This + * keeps re-homing deterministic in the webhook flow instead of depending on + * a client-side transfer call after checkout. + */ const userId = subscription.referenceId logger.info('Creating organization for team subscription', { @@ -165,10 +166,29 @@ export async function ensureOrganizationForTeamSubscription( subscription.enterpriseOperationId ?? null ) + /** + * Re-verify the pre-transaction membership read under the org + * mutation lock: a concurrent removal or role change must not let a + * stale admin membership authorize the transfer. + */ + const [lockedMembership] = await tx + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where( + and(eq(member.userId, userId), eq(member.organizationId, membership.organizationId)) + ) + .limit(1) + if (!lockedMembership || !isOrgAdminRole(lockedMembership.role)) { + throw new Error( + `User ${userId} no longer administers organization ${membership.organizationId}` + ) + } + const [lockedSub] = await tx .select({ id: subscriptionTable.id, referenceId: subscriptionTable.referenceId, + plan: subscriptionTable.plan, }) .from(subscriptionTable) .where(eq(subscriptionTable.id, subscription.id)) @@ -182,6 +202,12 @@ export async function ensureOrganizationForTeamSubscription( return } + if (!isOrgPlan(lockedSub.plan)) { + throw new Error( + `Subscription ${subscription.id} is no longer a team/enterprise plan (${lockedSub.plan})` + ) + } + const [lockedOrg] = await tx .select({ id: organization.id }) .from(organization) diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index c4a11c8e699..571c652b02e 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -312,6 +312,133 @@ export async function restoreUserProSubscription(userId: string): Promise { + const result: PauseProForOrgCoverageResult = { covered: false, paused: false } + + await db.transaction(async (tx) => { + await acquireUserBillingIdentityLock(tx, userId) + + /** + * Coverage is determined before (and independent of) the personal-sub + * lookup: `covered` reports the organization's coverage truth even when + * no entitled personal Pro row exists, exactly as the result contract + * promises. Callers gate free→paid transition handling on it, so a + * personal-sub lookup miss must not read as "not covered". + */ + const memberships = await tx + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + if (memberships.length === 0) return + + const organizationSubscriptions = await tx + .select({ plan: subscriptionTable.plan, referenceId: subscriptionTable.referenceId }) + .from(subscriptionTable) + .where( + and( + inArray( + subscriptionTable.referenceId, + memberships.map((membership) => membership.organizationId) + ), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + const coveringSubscription = organizationSubscriptions.find((organizationSubscription) => + isPaid(organizationSubscription.plan) + ) + if (!coveringSubscription) return + + result.covered = true + result.organizationId = coveringSubscription.referenceId + + const [personalPro] = await tx + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, userId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), + sqlIsPro(subscriptionTable.plan) + ) + ) + .for('update') + .limit(1) + + if (!personalPro) return + + result.subscriptionId = personalPro.id + + if (personalPro.cancelAtPeriodEnd) return + + await tx + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscriptionTable.id, personalPro.id)) + + if (personalPro.stripeSubscriptionId) { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: personalPro.stripeSubscriptionId, + subscriptionId: personalPro.id, + reason: 'covered-by-organization', + }) + } + + result.paused = true + }) + + if (result.paused) { + logger.warn( + 'Paused personal Pro created while covered by an organization subscription (kept until period end, Stripe sync queued)', + { + userId, + subscriptionId: result.subscriptionId, + organizationId: result.organizationId, + } + ) + } + + return result +} + export interface AddMemberParams { userId: string organizationId: string diff --git a/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts b/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts new file mode 100644 index 00000000000..4c8e50dedea --- /dev/null +++ b/apps/sim/lib/billing/organizations/pause-pro-for-coverage.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnqueueOutboxEvent } = vi.hoisted(() => ({ + mockEnqueueOutboxEvent: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/billing/storage/payer-transfer', () => ({ + changeOrganizationWorkspaceBilledAccountsInTx: vi.fn(), + changeWorkspaceStoragePayerInTx: vi.fn(), + changeWorkspaceStoragePayersInTx: vi.fn(), +})) +vi.mock('@/lib/core/outbox/service', () => ({ + enqueueOutboxEvent: mockEnqueueOutboxEvent, +})) + +import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' + +const ACTIVE_PERSONAL_PRO = { + id: 'sub-personal', + plan: 'pro_6000', + referenceId: 'user-1', + status: 'active', + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'stripe-sub-personal', +} + +/** + * Queues per-`where()` results for the three reads in the pause path: + * personal sub (`.for('update').limit(1)`), memberships (awaited where), + * and org subscriptions (awaited where). + */ +function queueWhereResponses(responses: unknown[][]) { + const queue = [...responses] + dbChainMockFns.where.mockImplementation(() => { + const result = queue.shift() ?? [] + const limit = vi.fn(() => Promise.resolve(result)) + const forResult = Promise.resolve(result) as Promise & { limit: typeof limit } + forResult.limit = limit + const thenable = Promise.resolve(result) as Promise & { + limit: typeof limit + for: () => typeof forResult + } + thenable.limit = limit + thenable.for = vi.fn(() => forResult) + return thenable as ReturnType + }) +} + +describe('pauseProSubscriptionForOrgCoverage', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('pauses the personal Pro and queues the Stripe sync when an entitled paid org covers the user', async () => { + queueWhereResponses([ + [{ organizationId: 'org-1' }], + [{ plan: 'team_6000', referenceId: 'org-1' }], + [ACTIVE_PERSONAL_PRO], + // update ... set ... where consumes one more where() call + [], + ]) + + const result = await pauseProSubscriptionForOrgCoverage('user-1') + + expect(result).toEqual({ + covered: true, + paused: true, + subscriptionId: 'sub-personal', + organizationId: 'org-1', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true }) + expect(mockEnqueueOutboxEvent).toHaveBeenCalledWith( + expect.anything(), + OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, + { + stripeSubscriptionId: 'stripe-sub-personal', + subscriptionId: 'sub-personal', + reason: 'covered-by-organization', + } + ) + }) + + it('reports covered even when no entitled personal Pro row exists', async () => { + queueWhereResponses([ + [{ organizationId: 'org-1' }], + [{ plan: 'team_6000', referenceId: 'org-1' }], + [], + ]) + + const result = await pauseProSubscriptionForOrgCoverage('user-1') + + expect(result).toEqual({ + covered: true, + paused: false, + organizationId: 'org-1', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('reports covered without pausing again when the personal Pro is already pausing', async () => { + queueWhereResponses([ + [{ organizationId: 'org-1' }], + [{ plan: 'team_6000', referenceId: 'org-1' }], + [{ ...ACTIVE_PERSONAL_PRO, cancelAtPeriodEnd: true }], + ]) + + const result = await pauseProSubscriptionForOrgCoverage('user-1') + + expect(result).toEqual({ + covered: true, + paused: false, + subscriptionId: 'sub-personal', + organizationId: 'org-1', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('reports not covered when the user is not a member of any organization', async () => { + queueWhereResponses([[]]) + + const result = await pauseProSubscriptionForOrgCoverage('user-1') + + expect(result).toEqual({ covered: false, paused: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('reports not covered when no org subscription is an entitled paid plan', async () => { + queueWhereResponses([[{ organizationId: 'org-1' }], []]) + + const result = await pauseProSubscriptionForOrgCoverage('user-1') + + expect(result).toEqual({ covered: false, paused: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index b74ed0ff448..e905c671399 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -47,11 +47,13 @@ export function isPaid(plan: string | null | undefined): boolean { } /** - * True when the plan **name** is a team/enterprise plan. This is a - * plan-name check, NOT a scope check — a `pro_*` plan attached to an - * organization is org-scoped at the billing level even though this - * returns `false` for it. For scope decisions use - * `isOrgScopedSubscription` (sync) or `isSubscriptionOrgScoped` (async). + * True when the plan **name** is a team/enterprise plan — the only plans + * that may be referenced to an organization (org-referenced subscriptions + * never hold `pro_*` plans; checkout authorization and the Stripe plan + * sync both enforce this). This is a plan-name check, NOT a scope check: + * a team plan can be transiently user-referenced between checkout and + * webhook re-homing. For scope decisions use `isOrgScopedSubscription` + * (sync) or `isSubscriptionOrgScoped` (async). */ export function isOrgPlan(plan: string | null | undefined): boolean { return isTeam(plan) || isEnterprise(plan) diff --git a/apps/sim/lib/billing/retention.test.ts b/apps/sim/lib/billing/retention.test.ts index f14e343fdfc..506e4c118ec 100644 --- a/apps/sim/lib/billing/retention.test.ts +++ b/apps/sim/lib/billing/retention.test.ts @@ -128,6 +128,45 @@ describe('resolveEffectivePiiRedaction', () => { expect(result.logs).toEqual({ enabled: true, entityTypes: ['PERSON'], language: 'en' }) }) + it('strips spaCy-NER entities from blockOutputs at resolve time (regex-only)', () => { + const result = resolveEffectivePiiRedaction({ + orgSettings: settings([ + { + id: 'r-1', + workspaceId: 'ws-1', + stages: { + input: stage(true, ['PERSON', 'EMAIL_ADDRESS']), + blockOutputs: stage(true, ['PERSON', 'EMAIL_ADDRESS']), + logs: stage(true, ['DATE_TIME']), + }, + }, + ]), + workspaceId: 'ws-1', + }) + // input + logs keep NER; blockOutputs drops it (regex-only execution path). + expect(result.input.entityTypes).toEqual(['PERSON', 'EMAIL_ADDRESS']) + expect(result.blockOutputs.entityTypes).toEqual(['EMAIL_ADDRESS']) + expect(result.logs.entityTypes).toEqual(['DATE_TIME']) + }) + + it('disables blockOutputs when only NER was stored (un-migrated rule)', () => { + const result = resolveEffectivePiiRedaction({ + orgSettings: settings([ + { + id: 'r-1', + workspaceId: 'ws-1', + stages: { + input: stage(false, []), + blockOutputs: stage(true, ['PERSON']), + logs: stage(false, []), + }, + }, + ]), + workspaceId: 'ws-1', + }) + expect(result.blockOutputs).toEqual(DISABLED) + }) + it('selects the whole workspace rule over the all rule (no per-stage merge)', () => { const result = resolveEffectivePiiRedaction({ orgSettings: settings([ diff --git a/apps/sim/lib/billing/retention.ts b/apps/sim/lib/billing/retention.ts index afc8e0c0c76..08eecafe26a 100644 --- a/apps/sim/lib/billing/retention.ts +++ b/apps/sim/lib/billing/retention.ts @@ -1,5 +1,9 @@ import type { DataRetentionSettings, PiiStagePolicy } from '@sim/db/schema' -import { coercePiiLanguage, DEFAULT_PII_LANGUAGE } from '@/lib/guardrails/pii-entities' +import { + coercePiiLanguage, + DEFAULT_PII_LANGUAGE, + stripNerEntities, +} from '@/lib/guardrails/pii-entities' /** Resolved policy for one redaction stage. */ export interface EffectivePiiStage { @@ -44,8 +48,16 @@ function sanitizeEntityTypes(value: unknown): string[] { * rejects enabled-with-no-types), so an empty entity list always means "off" — * consistent across the UI, the contract, and the masking layer. */ -function toEffectiveStage(policy: PiiStagePolicy | undefined): EffectivePiiStage { - const types = sanitizeEntityTypes(policy?.entityTypes) +function toEffectiveStage( + policy: PiiStagePolicy | undefined, + opts?: { regexOnly?: boolean } +): EffectivePiiStage { + // Block outputs are regex-only. Strip any spaCy-NER entity defensively here so + // execution never masks block outputs with NER, even for rules stored before + // the restriction (the write path already strips via the API contract). + const types = opts?.regexOnly + ? stripNerEntities(sanitizeEntityTypes(policy?.entityTypes)) + : sanitizeEntityTypes(policy?.entityTypes) if (!policy?.enabled || types.length === 0) return DISABLED_STAGE return { enabled: true, @@ -93,7 +105,7 @@ export function resolveEffectivePiiRedaction(params: { return { input: toEffectiveStage(rule.stages.input), - blockOutputs: toEffectiveStage(rule.stages.blockOutputs), + blockOutputs: toEffectiveStage(rule.stages.blockOutputs, { regexOnly: true }), logs: toEffectiveStage(rule.stages.logs), } } diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 321415adb23..6ce6c72f5de 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -150,75 +150,6 @@ async function transitionOrganizationToDormantState( } } -export async function handleOrganizationPlanDowngrade( - subscriptionData: { - id: string - plan: string | null - referenceId: string - status: string | null - }, - stripeEventId?: string -): Promise { - if (!(await isSubscriptionOrgScoped({ referenceId: subscriptionData.referenceId }))) { - return - } - - const stillTeamOrEnterprise = isTeam(subscriptionData.plan) || isEnterprise(subscriptionData.plan) - if (stillTeamOrEnterprise) { - return - } - - const [currentRow] = await db - .select({ plan: subscription.plan }) - .from(subscription) - .where(eq(subscription.id, subscriptionData.id)) - .limit(1) - - if (currentRow && (isTeam(currentRow.plan) || isEnterprise(currentRow.plan))) { - logger.info('Skipping plan downgrade transition - subscription is currently Team/Enterprise', { - subscriptionId: subscriptionData.id, - organizationId: subscriptionData.referenceId, - eventPlan: subscriptionData.plan, - currentPlan: currentRow.plan, - }) - return - } - - const idempotencyIdentifier = stripeEventId ?? `plan-downgrade:${subscriptionData.id}` - - try { - await stripeWebhookIdempotency.executeWithIdempotency( - 'organization-plan-downgrade', - idempotencyIdentifier, - async () => { - const result = await transitionOrganizationToDormantState( - subscriptionData.referenceId, - subscriptionData.id - ) - - if (result.workspacesDetached > 0 || result.restoredProCount > 0) { - logger.info('Transitioned organization to dormant state after plan downgrade', { - organizationId: subscriptionData.referenceId, - subscriptionId: subscriptionData.id, - plan: subscriptionData.plan, - ...result, - }) - } - - return result - } - ) - } catch (error) { - logger.error('Failed to transition organization to dormant state on plan downgrade', { - organizationId: subscriptionData.referenceId, - subscriptionId: subscriptionData.id, - plan: subscriptionData.plan, - error, - }) - throw error - } -} - /** * Handle new subscription creation - reset usage if transitioning from free to paid */ @@ -333,10 +264,10 @@ export async function handleSubscriptionCreated( /** * Handles a subscription deletion (cancel) event. Bills any final-period * overages, resets usage, and transitions the organization to a dormant - * state via `transitionOrganizationToDormantState` — the same path used - * by plan downgrades. Wrapped in `stripeWebhookIdempotency` so duplicate - * event deliveries collapse to one execution; if any step throws, the - * webhook retries from scratch. + * state via `transitionOrganizationToDormantState` — the sole trigger for + * detaching an organization's workspaces. Wrapped in + * `stripeWebhookIdempotency` so duplicate event deliveries collapse to one + * execution; if any step throws, the webhook retries from scratch. */ export async function handleSubscriptionDeleted( subscription: { diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index ac7ba92e790..c7c4fcea852 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -68,9 +68,17 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi case MothershipStreamV1EventType.text: if (block.lane === 'subagent') { if (block.channel === 'thinking') { - return { type: ContentBlockType.subagent_thinking, content: block.content } + return { + type: ContentBlockType.subagent_thinking, + content: block.content, + ...(block.agent ? { subagent: block.agent } : {}), + } + } + return { + type: ContentBlockType.subagent_text, + content: block.content, + ...(block.agent ? { subagent: block.agent } : {}), } - return { type: ContentBlockType.subagent_text, content: block.content } } if (block.channel === 'thinking') { return { type: ContentBlockType.thinking, content: block.content } @@ -118,6 +126,9 @@ function toDisplayContexts( ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), + ...(c.blockType ? { blockType: c.blockType } : {}), + ...(c.skillId ? { skillId: c.skillId } : {}), + ...(c.serverId ? { serverId: c.serverId } : {}), })) } diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts index 285743d37ac..10c74da0545 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts @@ -6,13 +6,18 @@ import { describe, expect, it } from 'vitest' import { buildEffectiveChatTranscript, getLiveAssistantMessageId, + isLiveAssistantMessageId, } from '@/lib/copilot/chat/effective-transcript' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, + MothershipStreamV1RunKind, MothershipStreamV1SessionKind, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, MothershipStreamV1TextChannel, + MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' @@ -149,7 +154,7 @@ describe('buildEffectiveChatTranscript', () => { ) }) - it('does not duplicate thinking-only text into a second assistant block', () => { + it('never surfaces thinking-channel text: a thinking-only stream stays a placeholder', () => { const result = buildEffectiveChatTranscript({ messages: [buildUserMessage('stream-1', 'Hello')], activeStreamId: 'stream-1', @@ -175,15 +180,51 @@ describe('buildEffectiveChatTranscript', () => { expect(result).toHaveLength(2) expect(result[1]).toEqual( expect.objectContaining({ - content: 'Internal reasoning', + id: getLiveAssistantMessageId('stream-1'), + role: 'assistant', + content: '', + }) + ) + expect(JSON.stringify(result[1])).not.toContain('Internal reasoning') + }) + + it('keeps assistant text while dropping interleaved thinking chunks', () => { + const textEvent = (seq: number, channel: MothershipStreamV1TextChannel, text: string) => + toBatchEvent(seq, { + v: 1, + seq, + ts: '2026-04-15T12:00:01.000Z', + type: MothershipStreamV1EventType.text, + stream: { streamId: 'stream-1' }, + payload: { channel, text }, + }) + const result = buildEffectiveChatTranscript({ + messages: [buildUserMessage('stream-1', 'Hello')], + activeStreamId: 'stream-1', + streamSnapshot: { + events: [ + textEvent(1, MothershipStreamV1TextChannel.thinking, '**Planning**\n\nHidden reasoning.'), + textEvent(2, MothershipStreamV1TextChannel.assistant, 'Visible answer.'), + textEvent(3, MothershipStreamV1TextChannel.thinking, 'More hidden reasoning.'), + ], + previewSessions: [], + status: 'active', + }, + }) + + expect(result).toHaveLength(2) + expect(result[1]).toEqual( + expect.objectContaining({ + content: 'Visible answer.', contentBlocks: [ expect.objectContaining({ type: MothershipStreamV1EventType.text, - content: 'Internal reasoning', + content: 'Visible answer.', }), ], }) ) + expect(JSON.stringify(result[1])).not.toContain('reasoning') }) it('treats user-cancelled tool results as cancelled', () => { @@ -228,6 +269,81 @@ describe('buildEffectiveChatTranscript', () => { ]) }) + it('pairs a scoped compaction inside the owning subagent during stream replay', () => { + const scope = { + lane: 'subagent' as const, + parentToolCallId: 'tc-workflow', + spanId: 'span-workflow', + parentSpanId: 'span-superagent', + agentId: 'superagent', + } + const stream = { streamId: 'stream-1' } + const result = buildEffectiveChatTranscript({ + messages: [buildUserMessage('stream-1', 'Hello')], + activeStreamId: 'stream-1', + streamSnapshot: { + events: [ + toBatchEvent(1, { + v: 1, + seq: 1, + ts: '2026-04-15T12:00:01.000Z', + type: MothershipStreamV1EventType.span, + stream, + scope, + payload: { + kind: MothershipStreamV1SpanPayloadKind.subagent, + event: MothershipStreamV1SpanLifecycleEvent.start, + agent: 'workflow', + data: { tool_call_id: 'tc-workflow' }, + }, + }), + toBatchEvent(2, { + v: 1, + seq: 2, + ts: '2026-04-15T12:00:02.000Z', + type: MothershipStreamV1EventType.run, + stream, + scope, + payload: { kind: MothershipStreamV1RunKind.compaction_start }, + }), + toBatchEvent(3, { + v: 1, + seq: 3, + ts: '2026-04-15T12:00:03.000Z', + type: MothershipStreamV1EventType.run, + stream, + scope, + payload: { + kind: MothershipStreamV1RunKind.compaction_done, + data: { summary_chars: 42 }, + }, + }), + ], + previewSessions: [], + status: 'active', + }, + }) + + const compactions = result[1]?.contentBlocks?.filter( + (block) => block.type === MothershipStreamV1EventType.tool + ) + expect(compactions).toHaveLength(1) + expect(compactions?.[0]).toEqual( + expect.objectContaining({ + parentToolCallId: 'tc-workflow', + spanId: 'span-workflow', + parentSpanId: 'span-superagent', + toolCall: expect.objectContaining({ + id: 'compaction_2', + name: 'context_compaction', + calledBy: 'workflow', + display: { title: 'Summarizing context' }, + state: MothershipStreamV1ToolOutcome.success, + }), + }) + ) + }) + it('materializes a cancelled assistant tail when the stream ends before persistence', () => { const result = buildEffectiveChatTranscript({ messages: [buildUserMessage('stream-1', 'Hello')], @@ -261,3 +377,99 @@ describe('buildEffectiveChatTranscript', () => { ) }) }) + +describe('isLiveAssistantMessageId', () => { + it('recognizes the synthetic live-assistant id and nothing else', () => { + expect(isLiveAssistantMessageId(getLiveAssistantMessageId('stream-1'))).toBe(true) + expect(isLiveAssistantMessageId('f620fceb-4e9d-4e7f-ab7f-890a2a823564')).toBe(false) + expect(isLiveAssistantMessageId('')).toBe(false) + }) +}) + +describe('tool ownership is call-frame authoritative', () => { + const toolEvent = ( + seq: number, + phase: 'call' | 'result', + toolCallId: string, + scope?: Record + ): StreamBatchEvent => + toBatchEvent(seq, { + v: 1, + seq, + ts: '2026-04-15T12:00:01.000Z', + type: MothershipStreamV1EventType.tool, + stream: { streamId: 'stream-1' }, + payload: + phase === 'call' + ? { phase: 'call', toolCallId, toolName: 'read', arguments: { path: 'a.md' } } + : { phase: 'result', toolCallId, toolName: 'read', success: true, output: 'ok' }, + ...(scope ? { scope } : {}), + // double-cast-allowed: synthetic test envelope; the reducer reads only the fields set here + } as unknown as StreamBatchEvent['event']) + + const superagentScope = { + lane: 'subagent', + agentId: 'superagent', + parentToolCallId: 'dispatch-1', + spanId: 'S1', + } + + const ownership = (result: ReturnType) => { + const blocks = (result[1].contentBlocks ?? []) as Array> + const tool = blocks.find((b) => b.type === MothershipStreamV1EventType.tool) + const tc = tool?.toolCall as Record | undefined + return { + calledBy: tc?.calledBy, + parentToolCallId: tool?.parentToolCallId, + spanId: tool?.spanId, + } + } + + it('an unscoped main call clears provisional subagent attribution', () => { + // The observed dev bug: a mis-scoped replayed result seeded the main + // read under Superagent, and nothing could ever move it back. + const result = buildEffectiveChatTranscript({ + messages: [buildUserMessage('stream-1', 'Hello')], + activeStreamId: 'stream-1', + streamSnapshot: { + events: [toolEvent(1, 'result', 'fc_1', superagentScope), toolEvent(2, 'call', 'fc_1')], + previewSessions: [], + status: 'active', + }, + }) + const own = ownership(result) + expect(own.calledBy).toBeUndefined() + expect(own.parentToolCallId).toBeUndefined() + expect(own.spanId).toBeUndefined() + }) + + it('a later mis-scoped result cannot re-parent a settled main tool', () => { + const result = buildEffectiveChatTranscript({ + messages: [buildUserMessage('stream-1', 'Hello')], + activeStreamId: 'stream-1', + streamSnapshot: { + events: [toolEvent(1, 'call', 'fc_1'), toolEvent(2, 'result', 'fc_1', superagentScope)], + previewSessions: [], + status: 'active', + }, + }) + const own = ownership(result) + expect(own.calledBy).toBeUndefined() + expect(own.parentToolCallId).toBeUndefined() + }) + + it('a genuinely scoped subagent call keeps its ownership', () => { + const result = buildEffectiveChatTranscript({ + messages: [buildUserMessage('stream-1', 'Hello')], + activeStreamId: 'stream-1', + streamSnapshot: { + events: [toolEvent(1, 'call', 'fc_2', superagentScope), toolEvent(2, 'result', 'fc_2')], + previewSessions: [], + status: 'active', + }, + }) + const own = ownership(result) + expect(own.calledBy).toBe('superagent') + expect(own.parentToolCallId).toBe('dispatch-1') + }) +}) diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index ae971047208..6594e0f8c7a 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -9,12 +9,16 @@ import { MothershipStreamV1SessionKind, MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, + MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { + CONTEXT_COMPACTION_DISPLAY_TITLE, + getToolDisplayTitle, +} from '@/lib/copilot/tools/tool-display' interface StreamSnapshotLike { events: StreamBatchEvent[] @@ -34,6 +38,16 @@ export function getLiveAssistantMessageId(streamId: string): string { return `live-assistant:${streamId}` } +/** + * True for the synthetic id of a streaming/just-streamed assistant message. + * These ids exist only in the client's effective transcript — never in the + * persisted one — so message-scoped server actions (e.g. fork) must not be + * offered until the transcript refetch swaps in the persisted message id. + */ +export function isLiveAssistantMessageId(messageId: string): boolean { + return messageId.startsWith('live-assistant:') +} + function asPayloadRecord(value: unknown): Record | undefined { return isRecordLike(value) ? value : undefined } @@ -105,7 +119,7 @@ function buildLiveAssistantMessage(params: { const subagentBySpanId = new Map() let activeSubagent: string | undefined let activeSubagentParentToolCallId: string | undefined - let activeCompactionId: string | undefined + const activeCompactionIdByLane = new Map() let runningText = '' let lastContentSource: 'main' | 'subagent' | null = null let requestId: string | undefined @@ -119,7 +133,6 @@ function buildLiveAssistantMessage(params: { parentToolCallId: string | undefined, spanId?: string ): string | undefined => { - if (agentId) return agentId if (spanId) { const scoped = subagentBySpanId.get(spanId) if (scoped) return scoped @@ -128,7 +141,7 @@ function buildLiveAssistantMessage(params: { const scoped = subagentByParentToolCallId.get(parentToolCallId) if (scoped) return scoped } - return undefined + return agentId } const resolveParentForSubagentBlock = ( @@ -139,6 +152,17 @@ function buildLiveAssistantMessage(params: { return scopedParent } + // Tool ownership (calledBy / parent / span identity) is CALL-FRAME + // authoritative: once a call frame for a tool id has been reduced, later + // scoped results or replayed duplicates must not re-parent the tool. Before + // a call frame arrives, ownership stays provisional (result-first replay + // arrival is legal) and the call frame settles it — including CLEARING + // stale subagent attribution when the call is main-lane (unscoped). Without + // the clear, one mis-scoped replayed event pinned main tools under a + // subagent (observed: Sim's reads rendered under Superagent) with no later + // event able to correct it. + const toolOwnershipSettled = new Set() + const ensureToolBlock = (input: { toolCallId: string toolName: string @@ -150,7 +174,11 @@ function buildLiveAssistantMessage(params: { params?: Record result?: { success: boolean; output?: unknown; error?: string } state?: string + isCallFrame?: boolean }): RawPersistedBlock => { + const ownershipWritable = + input.isCallFrame === true || !toolOwnershipSettled.has(input.toolCallId) + if (input.isCallFrame) toolOwnershipSettled.add(input.toolCallId) const existingIndex = toolIndexById.get(input.toolCallId) if (existingIndex !== undefined) { const existing = blocks[existingIndex] @@ -162,7 +190,7 @@ function buildLiveAssistantMessage(params: { state: input.state ?? (typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'), - ...(input.calledBy ? { calledBy: input.calledBy } : {}), + ...(ownershipWritable && input.calledBy ? { calledBy: input.calledBy } : {}), ...(input.params ? { params: input.params } : {}), ...(input.result ? { result: input.result } : {}), ...(input.displayTitle @@ -175,9 +203,21 @@ function buildLiveAssistantMessage(params: { ? { display: existingToolCall.display } : {}), } - if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId - if (input.spanId) existing.spanId = input.spanId - if (input.parentSpanId) existing.parentSpanId = input.parentSpanId + if (ownershipWritable) { + if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId + if (input.spanId) existing.spanId = input.spanId + if (input.parentSpanId) existing.parentSpanId = input.parentSpanId + if (input.isCallFrame && !input.calledBy) { + // Authoritative main-lane call: clear any provisionally-seeded + // subagent attribution so the tool renders under Sim, not the + // forwarding caller. + const tc = asPayloadRecord(existing.toolCall) + if (tc) tc.calledBy = undefined + existing.parentToolCallId = undefined + existing.spanId = undefined + existing.parentSpanId = undefined + } + } return existing } @@ -230,6 +270,11 @@ function buildLiveAssistantMessage(params: { ...(scopedSpanId ? { spanId: scopedSpanId } : {}), ...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}), } + const compactionLaneKey = scopedSpanId + ? `span:${scopedSpanId}` + : scopedParentToolCallId + ? `parent:${scopedParentToolCallId}` + : 'main' switch (parsed.type) { case MothershipStreamV1EventType.session: { @@ -249,6 +294,14 @@ function buildLiveAssistantMessage(params: { if (!chunk) { continue } + // Reasoning is never rendered or persisted (the stream reducer and the + // turn model both key on the channel; buildPersistedAssistantMessage + // strips it). This snapshot-derived converter must not resurrect it as + // visible prose — skip before block append AND runningText so thinking + // never leaks into the live-assistant message's content either. + if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) { + continue + } const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main' const needsBoundaryNewline = lastContentSource !== null && @@ -309,6 +362,7 @@ function buildLiveAssistantMessage(params: { ), params: isRecordLike(payload.arguments) ? payload.arguments : undefined, state: typeof payload.status === 'string' ? payload.status : 'executing', + isCallFrame: payload.phase === MothershipStreamV1ToolPhase.call, }) continue } @@ -375,23 +429,39 @@ function buildLiveAssistantMessage(params: { } case MothershipStreamV1EventType.run: { if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_start) { - activeCompactionId = `compaction_${entry.eventId}` + const compactionId = `compaction_${entry.eventId}` + activeCompactionIdByLane.set(compactionLaneKey, compactionId) + const parentForBlock = resolveParentForSubagentBlock( + scopedSubagent, + scopedParentToolCallId + ) ensureToolBlock({ - toolCallId: activeCompactionId, + toolCallId: compactionId, toolName: 'context_compaction', - displayTitle: 'Compacting context...', + calledBy: scopedSubagent, + ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), + ...spanIdentity, + displayTitle: CONTEXT_COMPACTION_DISPLAY_TITLE, state: 'executing', }) continue } if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_done) { - const compactionId = activeCompactionId ?? `compaction_${entry.eventId}` - activeCompactionId = undefined + const compactionId = + activeCompactionIdByLane.get(compactionLaneKey) ?? `compaction_${entry.eventId}` + activeCompactionIdByLane.delete(compactionLaneKey) + const parentForBlock = resolveParentForSubagentBlock( + scopedSubagent, + scopedParentToolCallId + ) ensureToolBlock({ toolCallId: compactionId, toolName: 'context_compaction', - displayTitle: 'Compacted context', + calledBy: scopedSubagent, + ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), + ...spanIdentity, + displayTitle: CONTEXT_COMPACTION_DISPLAY_TITLE, state: MothershipStreamV1ToolOutcome.success, }) } diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts b/apps/sim/lib/copilot/chat/fork-chat-files.test.ts new file mode 100644 index 00000000000..30092e59960 --- /dev/null +++ b/apps/sim/lib/copilot/chat/fork-chat-files.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateKey, mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({ + mockGenerateKey: vi.fn(), + mockDownloadFile: vi.fn(), + mockUploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: mockGenerateKey, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + uploadFile: mockUploadFile, +})) + +import { + executeChatFileBlobCopies, + type ForkableChatFileRow, + filterForkableChatFiles, + planChatFileCopies, +} from '@/lib/copilot/chat/fork-chat-files' + +const NOW = new Date('2026-07-08T00:00:00.000Z') + +function makeRow(overrides: Partial = {}): ForkableChatFileRow { + return { + id: 'wf_source', + key: 'workspace/ws-1/1-cat.png', + userId: 'user-1', + workspaceId: 'ws-1', + folderId: null, + context: 'mothership', + chatId: 'chat-1', + messageId: 'msg-1', + originalName: 'cat.png', + displayName: 'cat.png', + contentType: 'image/png', + size: 100, + deletedAt: null, + uploadedAt: new Date('2026-06-01T00:00:00.000Z'), + updatedAt: new Date('2026-06-01T00:00:00.000Z'), + ...overrides, + } as ForkableChatFileRow +} + +describe('planChatFileCopies', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGenerateKey.mockReturnValue('workspace/ws-1/2-cat.png') + }) + + it('copies a row under the fork with a fresh id + key and the SAME message_id', async () => { + const inserted: Array> = [] + const tx = { + insert: () => ({ + values: async (v: Record | Record[]) => { + inserted.push(...(Array.isArray(v) ? v : [v])) + }, + }), + } + + const { idMap, keyMap, blobTasks } = await planChatFileCopies({ + tx: tx as never, + rows: [makeRow()], + newChatId: 'chat-fork', + userId: 'user-1', + now: NOW, + }) + + expect(inserted).toHaveLength(1) + const copy = inserted[0] + expect(copy.id).not.toBe('wf_source') + expect(String(copy.id)).toMatch(/^wf_/) + expect(copy.key).toBe('workspace/ws-1/2-cat.png') + expect(copy.chatId).toBe('chat-fork') + expect(copy.messageId).toBe('msg-1') + expect(copy.displayName).toBe('cat.png') + expect(copy.deletedAt).toBeNull() + + expect(idMap.get('wf_source')).toBe(copy.id) + expect(keyMap.get('workspace/ws-1/1-cat.png')).toBe('workspace/ws-1/2-cat.png') + expect(blobTasks).toEqual([ + { + copyId: copy.id, + sourceKey: 'workspace/ws-1/1-cat.png', + targetKey: 'workspace/ws-1/2-cat.png', + context: 'mothership', + fileName: 'cat.png', + contentType: 'image/png', + }, + ]) + }) + + it('skips legacy rows with no workspaceId instead of failing the fork', async () => { + const inserted: Array> = [] + const tx = { + insert: () => ({ + values: async (v: Record | Record[]) => { + inserted.push(...(Array.isArray(v) ? v : [v])) + }, + }), + } + + const { idMap, blobTasks } = await planChatFileCopies({ + tx: tx as never, + rows: [makeRow({ workspaceId: null })], + newChatId: 'chat-fork', + userId: 'user-1', + now: NOW, + }) + + expect(inserted).toHaveLength(0) + expect(idMap.size).toBe(0) + expect(blobTasks).toHaveLength(0) + }) +}) + +describe('executeChatFileBlobCopies', () => { + const task = { + copyId: 'wf_copy', + sourceKey: 'workspace/ws-1/1-cat.png', + targetKey: 'workspace/ws-1/2-cat.png', + context: 'mothership' as const, + fileName: 'cat.png', + contentType: 'image/png', + } + + beforeEach(() => { + vi.clearAllMocks() + mockDownloadFile.mockResolvedValue(Buffer.from('0123456789')) + mockUploadFile.mockResolvedValue(undefined) + }) + + it('copies bytes to the new key without workspace storage accounting', async () => { + const result = await executeChatFileBlobCopies([task]) + + expect(result).toEqual({ copied: 1, failed: 0, failedCopyIds: [] }) + expect(mockUploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + customKey: 'workspace/ws-1/2-cat.png', + preserveKey: true, + }) + ) + // No `metadata` in the upload call — passing it would insert a second row. + expect(mockUploadFile.mock.calls[0][0].metadata).toBeUndefined() + }) + + it('is best-effort: a failed download skips the file and reports its copy id', async () => { + mockDownloadFile.mockRejectedValueOnce(new Error('blob missing')) + + const result = await executeChatFileBlobCopies([ + task, + { ...task, copyId: 'wf_copy2', targetKey: 'workspace/ws-1/3-cat.png' }, + ]) + + // The first task's download failed — its copy id comes back for row cleanup. + expect(result).toEqual({ copied: 1, failed: 1, failedCopyIds: ['wf_copy'] }) + }) + + it('copies every task even when the batch exceeds the concurrency bound', async () => { + const tasks = Array.from({ length: 9 }, (_, i) => ({ + ...task, + copyId: `wf_copy${i}`, + targetKey: `workspace/ws-1/${i}-cat.png`, + })) + + const result = await executeChatFileBlobCopies(tasks) + + expect(result.copied).toBe(9) + expect(mockUploadFile).toHaveBeenCalledTimes(9) + expect(new Set(mockUploadFile.mock.calls.map((c) => c[0].customKey)).size).toBe(9) + }) +}) + +describe('filterForkableChatFiles', () => { + it('keeps rows born in the kept slice plus NULL-birthdate legacy rows', () => { + const preCut = makeRow({ id: 'wf_pre', messageId: 'msg-1' }) + const postCut = makeRow({ id: 'wf_post', messageId: 'msg-3' }) + const legacy = makeRow({ id: 'wf_legacy', messageId: null }) + const secondPreCut = makeRow({ id: 'wf_pre2', messageId: 'msg-2' }) + + const kept = filterForkableChatFiles( + [preCut, postCut, legacy, secondPreCut], + new Set(['msg-1', 'msg-2']) + ) + + expect(kept.map((r) => r.id)).toEqual(['wf_pre', 'wf_legacy', 'wf_pre2']) + }) +}) diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.ts b/apps/sim/lib/copilot/chat/fork-chat-files.ts new file mode 100644 index 00000000000..f7dd90d0fa1 --- /dev/null +++ b/apps/sim/lib/copilot/chat/fork-chat-files.ts @@ -0,0 +1,206 @@ +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import type { DbOrTx } from '@/lib/db/types' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +const logger = createLogger('ForkChatFiles') + +/** + * The chat-owned storage context a fork copies: user uploads (`mothership`). + * A fork is a self-contained snapshot — bytes included (every copied row gets + * a fresh storage key; live rows can't share a key because of the + * `workspace_files_key_active_unique` index, and serve/view lookups resolve by + * key) — so the new chat survives deletion of the source chat. The copied set + * is timeline-cut to the fork point ({@link filterForkableChatFiles}). + * Shared workspace `files/` (`context='workspace'`) is workspace-owned, not + * chat-owned — both chats reference it in place and it is never copied. + */ +export const FORKABLE_CHAT_FILE_CONTEXT: StorageContext = 'mothership' + +/** Max concurrent blob byte-copies during a chat fork. */ +const CHAT_BLOB_COPY_CONCURRENCY = 4 + +export type ForkableChatFileRow = typeof workspaceFiles.$inferSelect + +/** One blob byte-copy to run after the fork transaction commits. */ +export interface ChatBlobCopyTask { + /** The copied `workspace_files` row's id — used to delete the row if the blob copy fails. */ + copyId: string + sourceKey: string + targetKey: string + context: StorageContext + fileName: string + contentType: string +} + +export interface PlanChatFileCopiesResult { + /** source `workspace_files.id` -> copy id (rewrites view-URLs, attachment ids, resource ids). */ + idMap: Map + /** source storage key -> copy storage key (rewrites serve-URLs, attachment keys). */ + keyMap: Map + /** Blob duplications to run after the transaction commits. */ + blobTasks: ChatBlobCopyTask[] +} + +/** + * Every live chat-owned file row (no timeline cut): the ghost test set for the + * resource-chip rewrite and the superset a fork cuts down in memory via + * {@link filterForkableChatFiles} — one `workspace_files` read serves both. + * Also used pre-transaction to sum sizes for the storage-quota gate. + */ +export async function listForkableChatFiles( + db: DbOrTx, + chatId: string +): Promise { + return db + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.chatId, chatId), + eq(workspaceFiles.context, FORKABLE_CHAT_FILE_CONTEXT), + isNull(workspaceFiles.deletedAt) + ) + ) +} + +/** + * The rows a fork copies out of the chat's owned files: those whose + * `message_id` is at-or-before the fork point (i.e. in the kept message + * slice). Rows with a NULL `message_id` — uploads that predate messageId + * stamping — are included in every fork of their chat: we can't know when + * they arrived, and copying them beats forking with broken references. Pure + * filter so the route reads `workspace_files` once per fork + * ({@link listForkableChatFiles}). + */ +export function filterForkableChatFiles( + rows: ForkableChatFileRow[], + keptMessageIds: ReadonlySet +): ForkableChatFileRow[] { + return rows.filter((row) => !row.messageId || keptMessageIds.has(row.messageId)) +} + +/** + * Insert copy rows for the kept chat-owned files under the new chat id (fresh + * `wf_` id + fresh storage key; `message_id` carries over verbatim so the copy + * matches the same message in the forked transcript; display names carry over + * verbatim because their uniqueness is per-chat and the new chat is an empty + * namespace). Returns the old->new id/key maps that drive the reference + * rewrite, plus the blob byte-copies to run post-commit. Runs inside the fork + * transaction so a failed insert rolls the whole fork back; blob I/O is + * deferred to {@link executeChatFileBlobCopies}. Modeled on the workspace-fork + * copy (`lib/workspaces/fork/copy/copy-files.ts`), adapted for chat-scoped rows. + */ +export async function planChatFileCopies(params: { + tx: DbOrTx + rows: ForkableChatFileRow[] + newChatId: string + userId: string + now: Date +}): Promise { + const { tx, rows, newChatId, userId, now } = params + const idMap = new Map() + const keyMap = new Map() + const blobTasks: ChatBlobCopyTask[] = [] + const copyRows: (typeof workspaceFiles.$inferInsert)[] = [] + + for (const row of rows) { + if (!row.workspaceId) { + logger.warn('Skipping chat file with no workspaceId during fork', { fileId: row.id }) + continue + } + const copyId = `wf_${generateShortId()}` + const targetKey = generateWorkspaceFileKey(row.workspaceId, row.originalName) + copyRows.push({ + ...row, + id: copyId, + key: targetKey, + chatId: newChatId, + userId, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + }) + idMap.set(row.id, copyId) + keyMap.set(row.key, targetKey) + blobTasks.push({ + copyId, + sourceKey: row.key, + targetKey, + context: row.context as StorageContext, + fileName: row.originalName, + contentType: row.contentType, + }) + } + + // Ids and keys are generated client-side, so one multi-row insert suffices — + // no per-row round trips while the fork transaction is held open. + if (copyRows.length > 0) { + await tx.insert(workspaceFiles).values(copyRows) + } + + return { idMap, keyMap, blobTasks } +} + +/** + * Copy each planned blob to its new key, best-effort: a failed copy logs a + * warning and is skipped (the fork keeps its transcript; that one file is + * missing) rather than failing the whole fork. Runs a bounded worker pool + * ({@link CHAT_BLOB_COPY_CONCURRENCY}) — media-heavy chats must not pay 2N + * serial storage round-trips, but unbounded fan-out would buffer every file + * in memory at once. Mothership files remain excluded from workspace storage + * accounting. Failed tasks' copy-row ids are returned so the caller can delete + * the dead rows (row exists, blob doesn't) instead of leaving them listed in + * the VFS and resources with nothing behind them. + */ +export async function executeChatFileBlobCopies( + blobTasks: ChatBlobCopyTask[] +): Promise<{ copied: number; failed: number; failedCopyIds: string[] }> { + let copied = 0 + const failedCopyIds: string[] = [] + + const copyOne = async (task: ChatBlobCopyTask): Promise => { + try { + // No replay guard here, unlike the workspace-fork copy this is modeled + // on: that path persists its task list in a trigger.dev job payload + // (replayable), while these tasks exist only in this request's memory + // and target keys are freshly minted per request — a HEAD check could + // never find an earlier attempt's object. + const buffer = await downloadFile({ + key: task.sourceKey, + context: task.context, + maxBytes: MAX_FILE_SIZE, + }) + // No `metadata` here on purpose: passing it would make uploadFile insert + // its own workspace_files row (without chatId), colliding with the row + // the transaction already created for this key. + await uploadFile({ + file: buffer, + fileName: task.fileName, + contentType: task.contentType, + context: task.context, + customKey: task.targetKey, + preserveKey: true, + }) + copied += 1 + } catch (error) { + failedCopyIds.push(task.copyId) + logger.warn('Failed to copy chat file blob during fork', { + sourceKey: task.sourceKey, + targetKey: task.targetKey, + error: getErrorMessage(error), + }) + } + } + + await mapWithConcurrency(blobTasks, CHAT_BLOB_COPY_CONCURRENCY, copyOne) + + return { copied, failed: failedCopyIds.length, failedCopyIds } +} diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 06e3a288e0a..f7a68910a00 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -33,6 +33,8 @@ vi.mock('@/tools/registry', () => ({ id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail', + outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, + oauth: { required: true, provider: 'google-email' }, }, brandfetch_search: { id: 'brandfetch_search', @@ -67,7 +69,13 @@ vi.mock('@/lib/copilot/integration-tools', () => ({ getExposedIntegrationTools: vi.fn(() => [ { toolId: 'gmail_send', - config: { id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail' }, + config: { + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send emails using Gmail', + outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, + oauth: { required: true, provider: 'google-email' }, + }, service: 'gmail', operation: 'send', }, @@ -154,6 +162,22 @@ describe('buildIntegrationToolSchemas', () => { expect(runTool?.executeLocally).toBe(true) }) + it('preserves operation, outputs, and OAuth discovery metadata', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + + const toolSchemas = await buildIntegrationToolSchemas('user-metadata') + const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send') + + expect(gmailTool).toEqual( + expect.objectContaining({ + service: 'gmail', + operation: 'send', + outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, + oauth: { required: true, provider: 'google-email' }, + }) + ) + }) + it('uses copilot-facing file schemas for integration tools', async () => { mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) @@ -174,11 +198,13 @@ describe('buildIntegrationToolSchemas', () => { const first = await buildIntegrationToolSchemas('user-cache') first[0].input_schema.mutated = true + if (first[0].outputs) first[0].outputs.mutated = true const second = await buildIntegrationToolSchemas('user-cache') expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1) expect(mockCreateUserToolSchema).toHaveBeenCalledTimes(3) expect(second[0].input_schema).not.toHaveProperty('mutated') + expect(second[0].outputs).not.toHaveProperty('mutated') }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 9bea8cddc10..b4f56f4b014 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -9,12 +9,12 @@ import { filterExposedIntegrationTools, getExposedIntegrationTools, } from '@/lib/copilot/integration-tools' +import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' -import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { stripVersionSuffix } from '@/tools/utils' @@ -33,6 +33,8 @@ interface BuildPayloadParams { model: string provider?: string contexts?: Array<{ type: string; content: string; tag?: string; path?: string }> + /** MCP servers explicitly tagged on this turn. Untagged servers stay unavailable. */ + mcpServerIds?: string[] fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }> commands?: string[] chatId?: string @@ -49,18 +51,26 @@ interface BuildPayloadParams { email?: string timezone?: string } - includeMothershipTools?: boolean } export interface ToolSchema { name: string description: string input_schema: Record + outputs?: Record defer_loading?: boolean executeLocally?: boolean params?: Record /** Canonical integration service/folder (e.g. "slack"), for server-side grouping. */ service?: string + /** + * Operation stem within the service — the VFS doc filename without `.json` + * (e.g. "list_users" for id "slack_list_users"). Stamped so the server can + * hand agents the exact `components/integrations/{service}/{operation}.json` + * path instead of making them derive it from the id (deriving is how the id + * gets guessed as the filename). + */ + operation?: string oauth?: { required: boolean; provider: string } } @@ -95,6 +105,7 @@ function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] { input_schema: { ...tool.input_schema }, } if (tool.params) cloned.params = { ...tool.params } + if (tool.outputs) cloned.outputs = structuredClone(tool.outputs) if (tool.oauth) cloned.oauth = { ...tool.oauth } return cloned }) @@ -205,7 +216,7 @@ async function buildIntegrationToolSchemasUncached( } const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis) - for (const { toolId, config: toolConfig, service } of exposedTools) { + for (const { toolId, config: toolConfig, service, operation } of exposedTools) { try { if (allowedIntegrations && toolIdToBlockType) { const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId)) @@ -220,12 +231,23 @@ async function buildIntegrationToolSchemasUncached( integrationTools.push({ name: toolId, service, + operation, description: getCopilotToolDescription(toolConfig, { isHosted, fallbackName: toolId, appendEmailTagline: shouldAppendEmailTagline, }), input_schema: { ...userSchema }, + ...(toolConfig.outputs && { + outputs: Object.fromEntries( + Object.entries(toolConfig.outputs) + .filter(([, output]) => output != null) + .map(([key, output]) => [ + key, + { type: output.type, description: output.description }, + ]) + ), + }), defer_loading: true, executeLocally: catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', @@ -305,7 +327,8 @@ export async function buildCopilotRequestPayload( f.key, filename, mediaType, - f.size + f.size, + userMessageId ) // Encode the read path per the percent-encoded VFS convention (matches // files/ and the uploads glob output). The materialize_file `fileName` @@ -343,30 +366,26 @@ export async function buildCopilotRequestPayload( const allContexts = [...(contexts ?? []), ...uploadContexts] let integrationTools: ToolSchema[] = [] - const mothershipTools: ToolSchema[] = [] + let mothershipTools: ToolSchema[] = [] const payloadLogger = logger.withMetadata({ messageId: userMessageId }) - if (effectiveMode === 'build') { + // "superagent" is a legacy wire value for Direct Action mode; both modes + // execute connected-service operations through the main-agent gateway. + if (effectiveMode === 'build' || effectiveMode === 'superagent') { integrationTools = await buildIntegrationToolSchemas( userId, userMessageId, { schemaSurface: 'copilot' }, params.workspaceId ) + } - if (params.includeMothershipTools && params.workspaceId) { - // Expose all workspace user-created skills via the single load_user_skill - // tool. Available to every user; content is fetched sim-side when the - // model calls it. - try { - const userSkillTool = await buildUserSkillTool(params.workspaceId) - if (userSkillTool) mothershipTools.push(userSkillTool) - } catch (error) { - logger.warn('Failed to build load_user_skill tool', { - error: toError(error).message, - }) - } - } + if (params.workspaceId && params.mcpServerIds?.length) { + mothershipTools = await buildTaggedMcpToolSchemas( + userId, + params.workspaceId, + params.mcpServerIds + ) } return { diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index afe43cf6a08..2b9e5d52e9e 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -90,10 +90,10 @@ describe('persisted-message', () => { const persisted = buildPersistedAssistantMessage(result) expect(persisted.content).not.toContain('sk-sim-secret-123') - expect(persisted.content).toContain('"redacted":true') + expect(persisted.content).toContain('{"type":"sim_key"}') const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text') expect(textBlock?.content).not.toContain('sk-sim-secret-123') - expect(textBlock?.content).toContain('"redacted":true') + expect(textBlock?.content).toContain('{"type":"sim_key"}') }) it('redacts sim_key credential tags split across streamed text chunks', () => { @@ -119,7 +119,7 @@ describe('persisted-message', () => { expect(persisted.contentBlocks).toBeDefined() const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('') expect(joined).not.toContain('sk-sim-secret-12345') - expect(joined).toContain('"redacted":true') + expect(joined).toContain('{"type":"sim_key"}') }) it('redacts the api key from a persisted generate_api_key tool result output', () => { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 15a5ef1ef63..41fe97ea31b 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -37,6 +37,13 @@ interface PersistedToolCall { export interface PersistedContentBlock { type: MothershipStreamV1EventType lane?: MothershipStreamV1StreamScope['lane'] + /** + * Subagent name on lane text blocks. The span-tree parser needs a name to + * create a group for content whose `subagent` start block is missing (resume + * legs re-emit text without re-emitting start); without it the prose is + * silently dropped on reload. + */ + agent?: string channel?: MothershipStreamV1TextChannel phase?: MothershipStreamV1ToolPhase kind?: MothershipStreamV1SpanPayloadKind @@ -68,6 +75,9 @@ interface PersistedMessageContext { fileId?: string folderId?: string chatId?: string + blockType?: string + skillId?: string + serverId?: string } export interface PersistedMessage { @@ -188,6 +198,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lane: 'subagent', channel: MothershipStreamV1TextChannel.assistant, content: block.content, + ...(block.subagent ? { agent: block.subagent } : {}), } case 'subagent_thinking': return { @@ -195,6 +206,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lane: 'subagent', channel: MothershipStreamV1TextChannel.thinking, content: block.content, + ...(block.subagent ? { agent: block.subagent } : {}), } case 'tool_call': { if (!block.toolCall) { @@ -260,7 +272,17 @@ export function buildPersistedAssistantMessage( } if (result.contentBlocks.length > 0) { - message.contentBlocks = mergeAndRedactPersistedBlocks(result.contentBlocks.map(mapContentBlock)) + // Reasoning is display-transient and never rendered, so it is never + // persisted either: storing it bloats whale chats and lets the persisted + // turn diverge from the streamed one (the refresh-vs-switch mismatch). + // This is the single write-side choke point for assistant blocks, so the + // guarantee holds for every terminal path (complete, cancelled, error). + const withoutThinking = result.contentBlocks.filter( + (block) => block.type !== 'thinking' && block.type !== 'subagent_thinking' + ) + if (withoutThinking.length > 0) { + message.contentBlocks = mergeAndRedactPersistedBlocks(withoutThinking.map(mapContentBlock)) + } } return message @@ -334,6 +356,9 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), + ...(c.blockType ? { blockType: c.blockType } : {}), + ...(c.skillId ? { skillId: c.skillId } : {}), + ...(c.serverId ? { serverId: c.serverId } : {}), })) } @@ -351,6 +376,7 @@ const CANONICAL_BLOCK_TYPES: Set = new Set(Object.values(MothershipStrea interface RawBlock { type: string lane?: string + agent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -416,6 +442,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { if (block.lane === 'subagent') { result.lane = block.lane } + if (block.agent) result.agent = block.agent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -649,6 +676,9 @@ export function normalizeMessage(raw: Record): PersistedMessage ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), + ...(c.blockType ? { blockType: c.blockType } : {}), + ...(c.skillId ? { skillId: c.skillId } : {}), + ...(c.serverId ? { serverId: c.serverId } : {}), })) } diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 465006952d2..92eea63c09f 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -295,6 +295,26 @@ describe('handleUnifiedChatPost', () => { ) }) + it('forwards slash-selected MCP server ids to the request-local tool builder', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: '/Docs search auth', + workspaceId: 'ws-1', + createNewChat: true, + contexts: [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], + }), + }) + ) + + expect(response.status).toBe(200) + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ mcpServerIds: ['mcp-server-1'] }), + { selectedModel: '' } + ) + }) + it('persists cancelled partial responses from the server lifecycle', async () => { await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index bd86a60c314..73b30948c4f 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -118,6 +118,7 @@ const ChatContextSchema = z.object({ 'scheduledtask', 'integration', 'skill', + 'mcp', ]), label: z.string(), chatId: z.string().optional(), @@ -131,6 +132,7 @@ const ChatContextSchema = z.object({ folderId: z.string().optional(), fileFolderId: z.string().optional(), skillId: z.string().optional(), + serverId: z.string().optional(), scheduleId: z.string().optional(), }) @@ -175,6 +177,7 @@ type UnifiedChatBranch = userMessageId: string chatId?: string contexts: Array<{ type: string; content: string; tag?: string; path?: string }> + mcpServerIds?: string[] fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string entitlements?: string[] @@ -213,6 +216,7 @@ type UnifiedChatBranch = userMessageId: string chatId?: string contexts: Array<{ type: string; content: string; tag?: string; path?: string }> + mcpServerIds?: string[] fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string entitlements?: string[] @@ -626,6 +630,7 @@ async function resolveBranch(params: { model: selectedModel, provider: payloadParams.provider, contexts: payloadParams.contexts, + mcpServerIds: payloadParams.mcpServerIds, fileAttachments: payloadParams.fileAttachments, commands: payloadParams.commands, chatId: payloadParams.chatId, @@ -685,6 +690,7 @@ async function resolveBranch(params: { mode: 'agent', model: '', contexts: payloadParams.contexts, + mcpServerIds: payloadParams.mcpServerIds, fileAttachments: payloadParams.fileAttachments, chatId: payloadParams.chatId, workspaceContext: payloadParams.workspaceContext, @@ -693,7 +699,6 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, - includeMothershipTools: true, }, { selectedModel: '' } ), @@ -997,14 +1002,18 @@ export async function handleUnifiedChatPost(req: NextRequest) { [TraceAttr.CopilotFileAttachmentsCount]: body.fileAttachments?.length ?? 0, [TraceAttr.CopilotContextsCount]: normalizedContexts.length, }, - () => - branch.kind === 'workflow' + () => { + const mcpServerIds = normalizedContexts.flatMap((context) => + context.kind === 'mcp' && context.serverId ? [context.serverId] : [] + ) + return branch.kind === 'workflow' ? branch.buildPayload({ message: body.message, userId: authenticatedUserId, userMessageId, chatId: actualChatId, contexts: agentContexts, + mcpServerIds, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, entitlements, @@ -1027,6 +1036,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMessageId, chatId: actualChatId, contexts: agentContexts, + mcpServerIds, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, entitlements, @@ -1034,7 +1044,8 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMetadata, workspaceContext, vfs, - }), + }) + }, activeOtelRoot.context ) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index e1d6ec21740..970e1a9a531 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -6,9 +6,13 @@ import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' -const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) +const { discoverServerTools, getSkillById } = vi.hoisted(() => ({ + discoverServerTools: vi.fn(), + getSkillById: vi.fn(), +})) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) +vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) /** * Overrides the global `@sim/db` mock: the logs-context tests below need * controllable row data, which the stable `dbChainMockFns.limit` provides. @@ -74,6 +78,40 @@ describe('processContextsServer - skill contexts', () => { }) }) +describe('processContextsServer - MCP contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists only the tools from the slash-selected MCP server', async () => { + discoverServerTools.mockResolvedValue([ + { + serverId: 'mcp-server-1', + serverName: 'Docs', + name: 'search', + description: 'Search documentation', + inputSchema: { type: 'object', properties: {} }, + }, + ]) + + const result = await processContextsServer( + [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], + 'user-1', + '/Docs find auth docs', + 'ws-1' + ) + + expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1') + expect(result).toEqual([ + expect.objectContaining({ + type: 'mcp', + tag: '/Docs', + content: expect.stringContaining('mcp-server-1-search'), + }), + ]) + }) +}) + describe('processContextsServer - logs contexts', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index a49ea07ccc0..610e79aa8ed 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -21,6 +21,8 @@ import { import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' +import { mcpService } from '@/lib/mcp/service' +import { createMcpToolId } from '@/lib/mcp/utils' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -81,6 +83,24 @@ export async function processContextsServer( ctx.label ? `@${ctx.label}` : '@' ) } + if (ctx.kind === 'mcp' && ctx.serverId && currentWorkspaceId) { + const tools = await mcpService.discoverServerTools(userId, ctx.serverId, currentWorkspaceId) + if (tools.length === 0) return null + const toolLines = tools.map((tool) => { + const name = createMcpToolId(tool.serverId, tool.name) + return `- ${name}: ${tool.description || tool.name}` + }) + return { + type: 'mcp', + tag: ctx.label ? `/${ctx.label}` : '/', + content: [ + `The user explicitly enabled the MCP server "${ctx.label || ctx.serverId}" for this turn.`, + 'Its request-scoped tools are listed below. Load a tool with load_custom_tool({ type: "mcp", name: "" }) before calling it.', + 'Do not narrate discovery, loading, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', + ...toolLines, + ].join('\n'), + } + } if (ctx.kind === 'past_chat' && ctx.chatId) { return await processPastChatFromDb( ctx.chatId, diff --git a/apps/sim/lib/copilot/chat/rewrite-file-references.ts b/apps/sim/lib/copilot/chat/rewrite-file-references.ts new file mode 100644 index 00000000000..021c6c19a2c --- /dev/null +++ b/apps/sim/lib/copilot/chat/rewrite-file-references.ts @@ -0,0 +1,99 @@ +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { rewriteForkContentRefs } from '@/ee/workspace-forking/lib/remap/remap-content-refs' + +/** + * Old->new translation tables produced while copying a chat's files + * (`planChatFileCopies`): row ids (view-URLs, attachment ids, resource ids, + * context chips) and storage keys (serve-URLs, attachment keys). + */ +export interface ChatFileRefMaps { + fileIds: ReadonlyMap + fileKeys: ReadonlyMap +} + +function hasMappings(maps: ChatFileRefMaps): boolean { + return maps.fileIds.size > 0 || maps.fileKeys.size > 0 +} + +function rewriteText(text: string, maps: ChatFileRefMaps): string { + return rewriteForkContentRefs(text, { fileIds: maps.fileIds, fileKeys: maps.fileKeys }) +} + +/** + * Re-point every file reference in a copied transcript at the copied files, so + * the fork is self-contained (it survives the original chat's deletion). + * Rewrites: free-text URLs in `content` and text content blocks (serve/view/ + * in-app/`sim:file` forms, via the shared fork grammar), attachment chip + * ids+keys, and `@`-mention context chip file ids. References to anything not + * in the maps (shared workspace files, workflows, other chats) pass through + * unchanged. Pure; returns the input array untouched when there is nothing to + * rewrite. + * + * Pass-through cannot leave the fork pointing at uncopied CHAT-OWNED files: + * a message can only reference files that existed when it was written, every + * chat-owned file is stamped with the user message of the turn it was born in + * (NULL-stamped legacy rows are copied into every fork), and the fork copies + * every chat-owned file born at-or-before the cut — so any chat-owned file a + * kept message references is always in the maps. The only reachable leftovers + * are files soft-deleted before the fork, whose links are equally dead in the + * source chat. + */ +export function rewriteMessageFileRefs( + messages: PersistedMessage[], + maps: ChatFileRefMaps +): PersistedMessage[] { + if (!hasMappings(maps)) return messages + return messages.map((message) => { + const rewritten: PersistedMessage = { + ...message, + content: rewriteText(message.content, maps), + } + if (message.contentBlocks?.length) { + rewritten.contentBlocks = message.contentBlocks.map((block) => + block.content ? { ...block, content: rewriteText(block.content, maps) } : block + ) + } + if (message.fileAttachments?.length) { + rewritten.fileAttachments = message.fileAttachments.map((att) => ({ + ...att, + id: maps.fileIds.get(att.id) ?? att.id, + key: maps.fileKeys.get(att.key) ?? att.key, + })) + } + if (message.contexts?.length) { + rewritten.contexts = message.contexts.map((ctx) => + ctx.fileId ? { ...ctx, fileId: maps.fileIds.get(ctx.fileId) ?? ctx.fileId } : ctx + ) + } + return rewritten + }) +} + +/** + * Re-point `file`-typed resource entries (the chat's attached-resources list + * stores raw `workspace_files.id`s) at the copied files. Non-file resources + * (workflows, tables, knowledge bases…) reference shared workspace entities + * and pass through unchanged. + * + * `dropFileIds` is the source chat's chat-owned file ids (no timeline cut). + * A file resource pointing at one of these that was NOT copied is a ghost in + * the new chat — its file stays behind (an upload born after the cut) — so it + * is dropped rather than left pointing at the source chat's file. Shared + * workspace files are not chat-owned, never appear in the set, and pass + * through unchanged. + */ +export function rewriteResourceFileRefs( + resources: MothershipResource[], + maps: ChatFileRefMaps, + dropFileIds?: ReadonlySet +): MothershipResource[] { + if (!hasMappings(maps) && !dropFileIds?.size) return resources + return resources.flatMap((resource) => { + if (resource.type !== 'file') return [resource] + const copyId = maps.fileIds.get(resource.id) + if (copyId) return [{ ...resource, id: copyId }] + if (dropFileIds?.has(resource.id)) return [] + return [resource] + }) +} diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts index 70fe637e6d2..0d2e28b8537 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts @@ -7,12 +7,21 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' import { captureRevealedSimKeys, extractRevealedSimKeys, + extractRevealedSimKeysFromBlocks, restoreRevealedSimKeysForMessage, + toolResultForModel, } from './sim-key-redaction' const credential = (value: string) => `${JSON.stringify({ value, type: 'sim_key' })}` const redacted = `${JSON.stringify({ type: 'sim_key', redacted: true })}` +// The value-less placeholder the model now emits (no `redacted` flag). +const placeholder = `${JSON.stringify({ type: 'sim_key' })}` + +const apiKeyBlock = (key: string) => ({ + type: 'tool_call' as const, + toolCall: { name: 'generate_api_key', result: { success: true, output: { id: 'k1', key } } }, +}) describe('sim-key-redaction', () => { describe('extractRevealedSimKeys', () => { @@ -28,6 +37,55 @@ describe('sim-key-redaction', () => { }) }) + describe('toolResultForModel', () => { + it('reduces a successful generate_api_key result to only its status message', () => { + const data = { + id: 'k1', + name: 'prod', + key: 'sk-sim-secret', + workspaceId: 'ws-1', + message: 'API key "prod" created.', + } + expect(toolResultForModel('generate_api_key', data)).toBe('API key "prod" created.') + }) + + it('leaves other tools untouched', () => { + const data = { key: 'not-a-secret', ok: true } + expect(toolResultForModel('read', data)).toBe(data) + }) + + it('passes generate_api_key errors through (no key to withhold)', () => { + const data = { error: 'name is required' } + expect(toolResultForModel('generate_api_key', data)).toBe(data) + expect(toolResultForModel('generate_api_key', undefined)).toBe(undefined) + }) + }) + + describe('extractRevealedSimKeysFromBlocks', () => { + it('pulls generate_api_key output keys in block order', () => { + expect( + extractRevealedSimKeysFromBlocks([apiKeyBlock('sk-sim-A'), apiKeyBlock('sk-sim-B')]) + ).toEqual(['sk-sim-A', 'sk-sim-B']) + }) + + it('skips redacted markers and unrelated tools', () => { + const blocks = [ + apiKeyBlock('[REDACTED]'), + { + type: 'tool_call' as const, + toolCall: { name: 'read', result: { success: true, output: { key: 'sk-x' } } }, + }, + apiKeyBlock('sk-sim-A'), + ] + expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(['sk-sim-A']) + }) + + it('returns nothing for empty/undefined block lists', () => { + expect(extractRevealedSimKeysFromBlocks(undefined)).toEqual([]) + expect(extractRevealedSimKeysFromBlocks([])).toEqual([]) + }) + }) + describe('captureRevealedSimKeys', () => { it('records new keys under each provided key', () => { const cache = new Map() @@ -59,6 +117,21 @@ describe('sim-key-redaction', () => { captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text') expect(cache.has('msg-1')).toBe(false) }) + + it('sources the key from the generate_api_key tool result (model text is a redacted placeholder)', () => { + const cache = new Map() + captureRevealedSimKeys(cache, ['msg-1', 'req-1'], `Here is your key: ${redacted}`, [ + apiKeyBlock('sk-sim-fromtool'), + ]) + expect(cache.get('msg-1')).toEqual(['sk-sim-fromtool']) + expect(cache.get('req-1')).toEqual(['sk-sim-fromtool']) + }) + + it('prefers tool-result keys over any inline content values', () => { + const cache = new Map() + captureRevealedSimKeys(cache, ['msg-1'], credential('sk-content'), [apiKeyBlock('sk-tool')]) + expect(cache.get('msg-1')).toEqual(['sk-tool']) + }) }) describe('restoreRevealedSimKeysForMessage', () => { @@ -76,6 +149,32 @@ describe('sim-key-redaction', () => { expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') }) + it('fills a value-less {"type":"sim_key"} placeholder (no redacted flag needed)', () => { + const cache = new Map([['msg-1', ['sk-sim-A']]]) + const msg: ChatMessage = { + id: 'msg-1', + role: 'assistant', + content: `Here is your key: ${placeholder} save it.`, + contentBlocks: [{ type: 'text', content: `Here is your key: ${placeholder} save it.` }], + } + const restored = restoreRevealedSimKeysForMessage(msg, cache) + expect(restored.content).toContain('"sk-sim-A"') + expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') + }) + + it('fills value-less and redacted placeholders positionally in one message', () => { + const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) + const msg: ChatMessage = { + id: 'msg-1', + role: 'assistant', + content: `first ${placeholder} second ${redacted}`, + } + const restored = restoreRevealedSimKeysForMessage(msg, cache) + expect(restored.content).toBe( + `first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}` + ) + }) + it('substitutes multiple keys in stream order', () => { const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) const msg: ChatMessage = { diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.ts index d5aba0f302d..023efd6baee 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message' import { MothershipStreamV1EventType, @@ -23,17 +24,15 @@ import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/ho */ const CREDENTIAL_TAG_PATTERN = /([\s\S]*?)<\/credential>/g -const REDACTED_TAG_PATTERN = /[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/ const SIM_KEY_TYPE = 'sim_key' -const REDACTED_SIM_KEY_TAG = `${JSON.stringify({ - type: SIM_KEY_TYPE, - redacted: true, -})}` +// The persisted / secret-stripped form of a sim_key tag: value-less, which is +// exactly how the UI renders the masked state. No `redacted` flag needed — a +// sim_key chip is masked iff it has no value. +const VALUELESS_SIM_KEY_TAG = `${JSON.stringify({ type: SIM_KEY_TYPE })}` interface CredentialTagBody { type?: unknown value?: unknown - redacted?: unknown } function parseCredentialBody(body: string): CredentialTagBody | null { @@ -44,22 +43,34 @@ function parseCredentialBody(body: string): CredentialTagBody | null { } } -function hasRedactedSimKeyTag(content: string | undefined): boolean { - return typeof content === 'string' && REDACTED_TAG_PATTERN.test(content) +/** + * True when `content` holds a `sim_key` credential tag that still needs its + * value filled in — i.e. any value-less `sim_key` tag: the model's + * `{"type":"sim_key"}` placeholder, the persisted form, or a legacy + * `{"type":"sim_key","redacted":true}` tag. All are recognized by the absence + * of a `value`. + */ +function hasFillableSimKeyTag(content: string | undefined): boolean { + if (typeof content !== 'string' || !content.includes('')) return false + for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { + const parsed = parseCredentialBody(match[1]) + if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) return true + } + return false } // Write side --------------------------------------------------------------- /** - * Replace every revealed `` tag in `content` with a - * placeholder marked `redacted: true`. Other credential types (e.g. OAuth - * `link`) and malformed bodies pass through unchanged. + * Replace every `` tag in `content` with the + * value-less placeholder, so a revealed key is never persisted. Other credential + * types (e.g. OAuth `link`) and malformed bodies pass through unchanged. */ export function redactSensitiveContent(content: T): T { if (typeof content !== 'string' || !content.includes('')) return content return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - return parsed?.type === SIM_KEY_TYPE ? REDACTED_SIM_KEY_TAG : match + return parsed?.type === SIM_KEY_TYPE ? VALUELESS_SIM_KEY_TAG : match }) as T } @@ -83,6 +94,23 @@ export function redactToolCallResult( } } +/** + * The model-facing result of `generate_api_key`. The generated key is a + * client-only artifact — it rides the SSE tool result to the browser and renders + * as the `sim_key` chip — so the model (and the persisted conversation) must + * never receive it. Rather than subtract the secret from the full payload, the + * model's result IS the status: on success it gets only the tool's message (no + * key, no id/name/workspaceId); a failure passes through so the model still sees + * the error. Every other tool's terminal data is returned unchanged. + */ +export function toolResultForModel(toolName: string | undefined, data: unknown): unknown { + if (toolName !== GenerateApiKey.id) return data + if (!isRecordLike(data)) return data + const record = data + if (typeof record.key !== 'string') return data + return record.message +} + function isMergeableAssistantTextBlock(block: PersistedContentBlock): boolean { return ( block.type === MothershipStreamV1EventType.text && @@ -107,6 +135,12 @@ export function mergeAndRedactPersistedBlocks( const out: PersistedContentBlock[] = [] let runStart = -1 let runLane: PersistedContentBlock['lane'] + // A run must stay within ONE lane instance: two parallel subagents both have + // lane 'subagent', and merging across them would append span B's prose into + // span A's block (B's spanId is lost with it). Key the run on span identity, + // not just the lane flag. + let runSpanId: PersistedContentBlock['spanId'] + let runParentToolCallId: PersistedContentBlock['parentToolCallId'] const flushRun = (endExclusive: number) => { if (runStart < 0) return @@ -129,12 +163,19 @@ export function mergeAndRedactPersistedBlocks( for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - const sameRun = runStart >= 0 && isMergeableAssistantTextBlock(block) && runLane === block.lane + const sameRun = + runStart >= 0 && + isMergeableAssistantTextBlock(block) && + runLane === block.lane && + runSpanId === block.spanId && + runParentToolCallId === block.parentToolCallId if (sameRun) continue flushRun(i) if (isMergeableAssistantTextBlock(block)) { runStart = i runLane = block.lane + runSpanId = block.spanId + runParentToolCallId = block.parentToolCallId } else { out.push(block) } @@ -156,34 +197,76 @@ export type RevealedSimKeysByMessage = Map /** * Scan an assembled assistant message for `` tags - * and return their values in stream order, skipping anything already redacted. + * and return their values in stream order; value-less (masked/placeholder) tags + * carry no string value and are skipped. */ export function extractRevealedSimKeys(content: string): string[] { if (!content || !content.includes('')) return [] const values: string[] = [] for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { const parsed = parseCredentialBody(match[1]) - if (parsed?.type === SIM_KEY_TYPE && !parsed.redacted && typeof parsed.value === 'string') { + if (parsed?.type === SIM_KEY_TYPE && typeof parsed.value === 'string') { values.push(parsed.value) } } return values } +/** Minimal shape of a rendered/streamed block carrying a tool result. */ +interface ToolResultBlockLike { + toolCall?: { name?: string; result?: unknown } | null +} + +/** + * Pull the freshly-generated key(s) out of `generate_api_key` tool results in + * block order. This is the authoritative source for the `sim_key` chip now that + * the model never sees (or emits) the value — it only emits a redacted + * placeholder, and the real value rides in the tool result on the live stream. + * `[REDACTED]` outputs (post-persist/refetch) are skipped so a reloaded + * transcript doesn't cache the masked marker over a live value. + */ +export function extractRevealedSimKeysFromBlocks( + blocks: ReadonlyArray | undefined +): string[] { + if (!blocks?.length) return [] + const values: string[] = [] + for (const block of blocks) { + const toolCall = block.toolCall + if (!toolCall || toolCall.name !== GenerateApiKey.id) continue + const result = toolCall.result + if (!isRecordLike(result)) continue + const output = result.output + if (!isRecordLike(output)) continue + const key = output.key + if (typeof key === 'string' && key.length > 0 && key !== REDACTED_MARKER) { + values.push(key) + } + } + return values +} + /** * Extend the cache entries for the given keys with any newly-revealed values. * Each key in `keys` is written the same array — passing both the live-stream * id and the persisted `requestId` lets the post-finalize refetch hit the * cache after the message is renamed to its real UUID. The longest captured * list wins so a rerun that surfaces fewer values can't shrink the entry. + * + * Values are sourced from the `generate_api_key` tool results (`blocks`) first — + * that is where the key now lives, since the model only emits a redacted + * placeholder tag — falling back to any inline `sim_key` tag values in + * `content` for backward compatibility with pre-change transcripts. */ export function captureRevealedSimKeys( cache: RevealedSimKeysByMessage, keys: ReadonlyArray, - content: string + content: string, + blocks?: ReadonlyArray ): void { - if (!content.includes('')) return - const next = extractRevealedSimKeys(content) + const fromBlocks = extractRevealedSimKeysFromBlocks(blocks) + // extractRevealedSimKeys already returns [] when `content` has no tag, so no + // separate includes() guard is needed. + const next = fromBlocks.length > 0 ? fromBlocks : extractRevealedSimKeys(content) if (next.length === 0) return for (const key of keys) { if (!key) continue @@ -208,7 +291,10 @@ function restoreInString( let changed = false const next = content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - if (parsed?.type === SIM_KEY_TYPE && parsed.redacted === true) { + // Any value-less sim_key tag is a fill slot — the model's placeholder, the + // persisted form, or a legacy `{"redacted":true}` tag. Already-filled tags + // carry a `value` and are left untouched (idempotent). + if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) { const value = revealedValues[cursor] cursor += 1 if (typeof value === 'string') { @@ -235,8 +321,8 @@ export function restoreRevealedSimKeysForMessage( cache.get(message.id) ?? (message.requestId ? cache.get(message.requestId) : undefined) if (!revealed || revealed.length === 0) return message if ( - !hasRedactedSimKeyTag(message.content) && - !message.contentBlocks?.some((b) => hasRedactedSimKeyTag(b.content)) + !hasFillableSimKeyTag(message.content) && + !message.contentBlocks?.some((b) => hasFillableSimKeyTag(b.content)) ) { return message } @@ -245,7 +331,7 @@ export function restoreRevealedSimKeysForMessage( let blocksChanged = false let blockCursor = 0 const nextBlocks: ContentBlock[] | undefined = message.contentBlocks?.map((block) => { - if (!hasRedactedSimKeyTag(block.content)) return block + if (!hasFillableSimKeyTag(block.content)) return block const restored = restoreInString(block.content as string, revealed, blockCursor) blockCursor = restored.cursor if (!restored.changed) return block diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 7558b06f56c..0eed19fdee8 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -291,8 +291,8 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { .sort(byNameThenId) .map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) sections.push( - `## Skills (${data.skills.length})\n` + - 'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' + + `## Agent Block Skills — NOT FOR YOU (${data.skills.length})\n` + + 'These are user-created skills used by agent blocks in the workspace and are NOT instructions for you\n' + lines.join('\n') ) } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cce8011cd3e..d2bdf083b2f 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,11 +9,12 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' + | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -50,7 +51,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -64,42 +64,40 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_integration_tools' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' - | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -108,11 +106,12 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' + | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -149,7 +148,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -163,47 +161,45 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_integration_tools' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' - | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' parameters: unknown - requiredPermission?: 'admin' | 'read' | 'write' + requiredPermission?: 'admin' | 'write' resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: @@ -213,10 +209,9 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' - | 'research' | 'run' | 'scheduled_task' - | 'superagent' + | 'search' | 'table' | 'workflow' } @@ -254,6 +249,35 @@ export const Auth: ToolCatalogEntry = { internal: true, } +export const CallIntegrationTool: ToolCatalogEntry = { + id: 'call_integration_tool', + name: 'call_integration_tool', + route: 'go', + mode: 'sync', + parameters: { + properties: { + arguments: { + additionalProperties: true, + description: "Inputs matching the selected operation's server-owned inputSchema.", + type: 'object', + }, + credentialId: { + description: + 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', + type: 'string', + }, + description: { + description: + 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', + type: 'string', + }, + toolId: { description: 'Exact toolId returned by search_integration_tools.', type: 'string' }, + }, + required: ['toolId', 'description', 'arguments'], + type: 'object', + }, +} + export const CheckDeploymentStatus: ToolCatalogEntry = { id: 'check_deployment_status', name: 'check_deployment_status', @@ -284,6 +308,36 @@ export const CompleteScheduledTask: ToolCatalogEntry = { }, } +export const Cp: ToolCatalogEntry = { + id: 'cp', + name: 'cp', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const CrawlWebsite: ToolCatalogEntry = { id: 'crawl_website', name: 'crawl_website', @@ -335,7 +389,7 @@ export const CreateFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -377,29 +431,6 @@ export const CreateFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const CreateFileFolder: ToolCatalogEntry = { - id: 'create_file_folder', - name: 'create_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - requiredPermission: 'write', -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -1026,7 +1057,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1301,7 +1332,8 @@ export const Ffmpeg: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1465,7 +1497,8 @@ export const FunctionExecute: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1498,6 +1531,12 @@ export const FunctionExecute: ToolCatalogEntry = { }, }, }, + timeout: { + type: 'number', + description: + 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + default: 10, + }, title: { type: 'string', description: @@ -1630,7 +1669,8 @@ export const GenerateAudio: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1764,7 +1804,8 @@ export const GenerateImage: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1922,7 +1963,8 @@ export const GenerateVideo: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -2411,23 +2453,6 @@ export const KnowledgeBase: ToolCatalogEntry = { }, } -export const ListFileFolders: ToolCatalogEntry = { - id: 'list_file_folders', - name: 'list_file_folders', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - requiredPermission: 'read', -} - export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', @@ -2616,35 +2641,16 @@ export const ManageFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', - }, + operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', }, }, required: ['operation'], @@ -2861,72 +2867,57 @@ export const Media: ToolCatalogEntry = { internal: true, } -export const MoveFile: ToolCatalogEntry = { - id: 'move_file', - name: 'move_file', +export const Mkdir: ToolCatalogEntry = { + id: 'mkdir', + name: 'mkdir', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string' }, }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const MoveFileFolder: ToolCatalogEntry = { - id: 'move_file_folder', - name: 'move_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, requiredPermission: 'write', } -export const MoveWorkflow: ToolCatalogEntry = { - id: 'move_workflow', - name: 'move_workflow', +export const Mv: ToolCatalogEntry = { + id: 'mv', + name: 'mv', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string' }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, requiredPermission: 'write', } @@ -2939,6 +2930,11 @@ export const OauthGetAuthLink: ToolCatalogEntry = { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -3130,6 +3126,55 @@ export const QueryLogs: ToolCatalogEntry = { }, } +export const QueryUserTable: ToolCatalogEntry = { + id: 'query_user_table', + name: 'query_user_table', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3230,87 +3275,6 @@ export const Redeploy: ToolCatalogEntry = { requiredPermission: 'admin', } -export const RenameFile: ToolCatalogEntry = { - id: 'rename_file', - name: 'rename_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { type: 'object', description: 'Contains id and the new name.' }, - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the rename succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const RenameFileFolder: ToolCatalogEntry = { - id: 'rename_file_folder', - name: 'rename_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'New folder name.' }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - requiredPermission: 'write', -} - -export const RenameWorkflow: ToolCatalogEntry = { - id: 'rename_workflow', - name: 'rename_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'The new name for the workflow.' }, - workflowId: { type: 'string', description: 'The workflow ID to rename.' }, - }, - required: ['workflowId', 'name'], - }, - requiredPermission: 'write', -} - -export const Research: ToolCatalogEntry = { - id: 'research', - name: 'research', - route: 'subagent', - mode: 'async', - parameters: { - properties: { topic: { description: 'The topic to research.', type: 'string' } }, - required: ['topic'], - type: 'object', - }, - subagentId: 'research', - internal: true, -} - export const Respond: ToolCatalogEntry = { id: 'respond', name: 'respond', @@ -3324,6 +3288,12 @@ export const Respond: ToolCatalogEntry = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { type: 'string' }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean' }, type: { description: 'Optional logical result type override', type: 'string' }, }, @@ -3408,6 +3378,99 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } +export const RunCode: ToolCatalogEntry = { + id: 'run_code', + name: 'run_code', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'table_input'], +} + export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -3571,6 +3634,26 @@ export const ScrapePage: ToolCatalogEntry = { }, } +export const Search: ToolCatalogEntry = { + id: 'search', + name: 'search', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'search', + internal: true, +} + export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3586,6 +3669,77 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } +export const SearchIntegrationTools: ToolCatalogEntry = { + id: 'search_integration_tools', + name: 'search_integration_tools', + route: 'go', + mode: 'sync', + parameters: { + properties: { + limit: { + description: 'Maximum matches to return. Defaults to 5.', + maximum: 10, + minimum: 1, + type: 'integer', + }, + query: { + description: 'What the service operation must do, in plain language.', + type: 'string', + }, + service: { + description: + 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', + type: 'string', + }, + }, + required: ['query'], + type: 'object', + }, +} + +export const SearchKnowledgeBase: ToolCatalogEntry = { + id: 'search_knowledge_base', + name: 'search_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -3768,26 +3922,6 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { requiredPermission: 'write', } -export const Superagent: ToolCatalogEntry = { - id: 'superagent', - name: 'superagent', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - task: { - description: - "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - subagentId: 'superagent', - internal: true, -} - export const Table: ToolCatalogEntry = { id: 'table', name: 'table', @@ -3870,50 +4004,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const UserMemory: ToolCatalogEntry = { - id: 'user_memory', - name: 'user_memory', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { type: 'number', description: 'Number of results for search (default 10)' }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { type: 'string', description: 'Search query to find relevant memories' }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { type: 'string', description: 'Value to remember' }, - }, - required: ['operation'], - }, -} - export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4256,10 +4346,23 @@ export const Workflow: ToolCatalogEntry = { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, subagentId: 'workflow', @@ -4495,21 +4598,13 @@ export const ManageCustomToolOperationValues = [ ] as const export const ManageFolderOperation = { - create: 'create', - rename: 'rename', - move: 'move', delete: 'delete', } as const export type ManageFolderOperation = (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] -export const ManageFolderOperationValues = [ - ManageFolderOperation.create, - ManageFolderOperation.rename, - ManageFolderOperation.move, - ManageFolderOperation.delete, -] as const +export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const export const ManageMcpToolOperation = { add: 'add', @@ -4576,22 +4671,36 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const -export const UserMemoryOperation = { - add: 'add', - search: 'search', - delete: 'delete', - correct: 'correct', - list: 'list', +export const QueryUserTableOperation = { + get: 'get', + getSchema: 'get_schema', + getRow: 'get_row', + queryRows: 'query_rows', +} as const + +export type QueryUserTableOperation = + (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] + +export const QueryUserTableOperationValues = [ + QueryUserTableOperation.get, + QueryUserTableOperation.getSchema, + QueryUserTableOperation.getRow, + QueryUserTableOperation.queryRows, +] as const + +export const SearchKnowledgeBaseOperation = { + get: 'get', + query: 'query', + listTags: 'list_tags', } as const -export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] +export type SearchKnowledgeBaseOperation = + (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] -export const UserMemoryOperationValues = [ - UserMemoryOperation.add, - UserMemoryOperation.search, - UserMemoryOperation.delete, - UserMemoryOperation.correct, - UserMemoryOperation.list, +export const SearchKnowledgeBaseOperationValues = [ + SearchKnowledgeBaseOperation.get, + SearchKnowledgeBaseOperation.query, + SearchKnowledgeBaseOperation.listTags, ] as const export const UserTableOperation = { @@ -4682,11 +4791,12 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, + [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteScheduledTask.id]: CompleteScheduledTask, + [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, - [CreateFileFolder.id]: CreateFileFolder, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteFile.id]: DeleteFile, @@ -4723,7 +4833,6 @@ export const TOOL_CATALOG: Record = { [Grep.id]: Grep, [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, - [ListFileFolders.id]: ListFileFolders, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -4737,42 +4846,40 @@ export const TOOL_CATALOG: Record = { [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, - [MoveFile.id]: MoveFile, - [MoveFileFolder.id]: MoveFileFolder, - [MoveWorkflow.id]: MoveWorkflow, + [Mkdir.id]: Mkdir, + [Mv.id]: Mv, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, + [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, - [RenameFile.id]: RenameFile, - [RenameFileFolder.id]: RenameFileFolder, - [RenameWorkflow.id]: RenameWorkflow, - [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, + [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, + [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, + [SearchIntegrationTools.id]: SearchIntegrationTools, + [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, - [Superagent.id]: Superagent, [Table.id]: Table, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, - [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 2d9678086ff..ba7e89ab8fa 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,6 +36,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + call_integration_tool: { + parameters: { + properties: { + arguments: { + additionalProperties: true, + description: "Inputs matching the selected operation's server-owned inputSchema.", + type: 'object', + }, + credentialId: { + description: + 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', + type: 'string', + }, + description: { + description: + 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', + type: 'string', + }, + toolId: { + description: 'Exact toolId returned by search_integration_tools.', + type: 'string', + }, + }, + required: ['toolId', 'description', 'arguments'], + type: 'object', + }, + resultSchema: undefined, + }, check_deployment_status: { parameters: { type: 'object', @@ -61,6 +89,33 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + cp: { + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + resultSchema: undefined, + }, crawl_website: { parameters: { type: 'object', @@ -117,7 +172,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -162,24 +217,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - resultSchema: undefined, - }, create_workflow: { parameters: { type: 'object', @@ -854,7 +891,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1124,7 +1161,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1291,7 +1329,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1324,6 +1363,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + timeout: { + type: 'number', + description: + 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + default: 10, + }, title: { type: 'string', description: @@ -1452,7 +1497,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1589,7 +1635,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1747,7 +1794,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -2233,18 +2281,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - resultSchema: undefined, - }, list_integration_tools: { parameters: { properties: { @@ -2428,35 +2464,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, operation: { type: 'string', description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + enum: ['delete'], }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', }, }, required: ['operation'], @@ -2660,62 +2681,52 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + mkdir: { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', }, }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - move_file_folder: { - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, resultSchema: undefined, }, - move_workflow: { + mv: { parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, resultSchema: undefined, }, @@ -2723,6 +2734,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -2911,6 +2927,68 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + query_user_table: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: 'MongoDB-style filter for query_rows', + }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { + type: 'string', + description: 'Row ID (required for get_row)', + }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { + type: 'string', + description: 'Table ID (required for all operations)', + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, read: { parameters: { type: 'object', @@ -3017,89 +3095,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: - 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: 'Contains id and the new name.', - }, - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the rename succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - rename_file_folder: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'New folder name.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - resultSchema: undefined, - }, - rename_workflow: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The new name for the workflow.', - }, - workflowId: { - type: 'string', - description: 'The workflow ID to rename.', - }, - }, - required: ['workflowId', 'name'], - }, - resultSchema: undefined, - }, - research: { - parameters: { - properties: { - topic: { - description: 'The topic to research.', - type: 'string', - }, - }, - required: ['topic'], - type: 'object', - }, - resultSchema: undefined, - }, respond: { parameters: { additionalProperties: true, @@ -3109,6 +3104,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { + type: 'string', + }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean', @@ -3190,6 +3193,99 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + run_code: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, run_from_block: { parameters: { type: 'object', @@ -3337,6 +3433,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + search: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, search_documentation: { parameters: { type: 'object', @@ -3354,6 +3464,80 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + search_integration_tools: { + parameters: { + properties: { + limit: { + description: 'Maximum matches to return. Defaults to 5.', + maximum: 10, + minimum: 1, + type: 'integer', + }, + query: { + description: 'What the service operation must do, in plain language.', + type: 'string', + }, + service: { + description: + 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', + type: 'string', + }, + }, + required: ['query'], + type: 'object', + }, + resultSchema: undefined, + }, + search_knowledge_base: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, search_library_docs: { parameters: { type: 'object', @@ -3534,20 +3718,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { - parameters: { - properties: { - task: { - description: - "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - resultSchema: undefined, - }, table: { parameters: { properties: { @@ -3633,55 +3803,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { - type: 'number', - description: 'Number of results for search (default 10)', - }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { - type: 'string', - description: 'Search query to find relevant memories', - }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { - type: 'string', - description: 'Value to remember', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, user_table: { parameters: { type: 'object', @@ -4050,10 +4171,23 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, resultSchema: undefined, diff --git a/apps/sim/lib/copilot/integration-tools.test.ts b/apps/sim/lib/copilot/integration-tools.test.ts new file mode 100644 index 00000000000..d55e6f15f0a --- /dev/null +++ b/apps/sim/lib/copilot/integration-tools.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/blocks/registry-maps', () => ({ + BLOCK_REGISTRY: { + svc: { + type: 'svc', + tools: { access: ['svc_send_v2'] }, + }, + // Preview successor sharing the released block's tools (the slack/slack_v2 + // paradigm) — must not become the owner of the shared tools. + svc_v2: { + type: 'svc_v2', + preview: true, + tools: { access: ['svc_send_v2'] }, + }, + // Preview block with tools of its own — stays preview-owned and gated. + newsvc: { + type: 'newsvc', + preview: true, + tools: { access: ['newsvc_do_v1'] }, + }, + }, +})) + +vi.mock('@/tools/registry', () => ({ + tools: { + svc_send_v1: { name: 'Send (legacy)' }, + svc_send_v2: { name: 'Send' }, + newsvc_do_v1: { name: 'Do' }, + }, +})) + +import { + filterExposedIntegrationTools, + getExposedIntegrationTools, + resetExposedIntegrationToolsCache, +} from '@/lib/copilot/integration-tools' + +describe('getExposedIntegrationTools', () => { + beforeEach(() => { + resetExposedIntegrationToolsCache() + }) + + it('keeps the released block as owner of tools a preview block shares', () => { + const send = getExposedIntegrationTools().find((t) => t.toolId === 'svc_send_v2') + expect(send).toBeDefined() + expect(send?.blockType).toBe('svc') + expect(send?.preview).toBeFalsy() + }) + + it('exposes shared tools to viewers without the preview reveal, but not preview-only tools', () => { + const visible = filterExposedIntegrationTools(getExposedIntegrationTools(), null) + expect(visible.some((t) => t.toolId === 'svc_send_v2')).toBe(true) + expect(visible.some((t) => t.toolId === 'newsvc_do_v1')).toBe(false) + }) + + it('exposes only the latest version of each tool', () => { + const exposed = getExposedIntegrationTools() + expect(exposed.some((t) => t.toolId === 'svc_send_v1')).toBe(false) + expect(exposed.some((t) => t.toolId === 'svc_send_v2')).toBe(true) + }) +}) diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/copilot/integration-tools.ts index 77559d78784..79b92e53bb5 100644 --- a/apps/sim/lib/copilot/integration-tools.ts +++ b/apps/sim/lib/copilot/integration-tools.ts @@ -53,8 +53,15 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] { const service = stripVersionSuffix(block.type) const owner = { service, blockType: block.type, preview: block.preview } for (const toolId of block.tools.access) { - toolToBlock.set(toolId, owner) - toolToBlock.set(stripVersionSuffix(toolId), owner) + for (const key of [toolId, stripVersionSuffix(toolId)]) { + // A preview block must not steal ownership of tools it shares with a + // released block (e.g. slack_v2 spreads slack's tools.access), or the + // per-viewer filter would hide those tools from everyone without the + // preview reveal. + const existing = toolToBlock.get(key) + if (existing && !existing.preview && owner.preview) continue + toolToBlock.set(key, owner) + } } } diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/copilot/mcp-tools.test.ts new file mode 100644 index 00000000000..a41057d6d8d --- /dev/null +++ b/apps/sim/lib/copilot/mcp-tools.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { discoverServerTools, validateMcpToolsAllowed } = vi.hoisted(() => ({ + discoverServerTools: vi.fn(), + validateMcpToolsAllowed: vi.fn(), +})) + +vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateMcpToolsAllowed })) + +import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from './mcp-tools' + +describe('mothership MCP tool schemas', () => { + beforeEach(() => { + vi.clearAllMocks() + validateMcpToolsAllowed.mockResolvedValue(undefined) + }) + + it('discovers tools only for explicitly tagged servers', async () => { + discoverServerTools.mockResolvedValue([ + { + serverId: 'mcp-server-1', + serverName: 'Docs', + name: 'search', + description: 'Search docs', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + ]) + + const tools = await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1']) + + expect(discoverServerTools).toHaveBeenCalledTimes(1) + expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1') + expect(tools).toEqual([ + expect.objectContaining({ + name: 'mcp-server-1-search', + defer_loading: true, + executeLocally: false, + params: expect.objectContaining({ + mothershipToolKind: 'mcp', + mothershipToolName: 'mcp-server-1-search', + serverId: 'mcp-server-1', + toolName: 'search', + }), + }), + ]) + }) + + it('uses a selected block tool cached schema without discovering the server', async () => { + const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [ + { + type: 'mcp', + params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, + schema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + ]) + + expect(discoverServerTools).not.toHaveBeenCalled() + expect(tools[0]).toMatchObject({ + name: 'mcp-server-1-search', + input_schema: { type: 'object', properties: { query: { type: 'string' } } }, + }) + }) +}) diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/copilot/mcp-tools.ts new file mode 100644 index 00000000000..0316e1eb55d --- /dev/null +++ b/apps/sim/lib/copilot/mcp-tools.ts @@ -0,0 +1,137 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { ToolSchema } from '@/lib/copilot/chat/payload' +import type { McpTool, McpToolSchema } from '@/lib/mcp/types' +import { createMcpToolId } from '@/lib/mcp/utils' +import { validateMcpToolsAllowed } from '@/ee/access-control/utils/permission-check' +import type { ToolInput } from '@/executor/handlers/agent/types' + +const logger = createLogger('CopilotMcpTools') + +function toMothershipMcpTool(tool: { + serverId: string + serverName?: string + name: string + description?: string + inputSchema: McpToolSchema | Record +}): ToolSchema { + const callableName = createMcpToolId(tool.serverId, tool.name) + return { + name: callableName, + description: + tool.description || `MCP tool ${tool.name} from ${tool.serverName || tool.serverId}`, + input_schema: tool.inputSchema, + defer_loading: true, + executeLocally: false, + service: `mcp:${tool.serverId}`, + params: { + mothershipToolKind: 'mcp', + mothershipToolName: callableName, + mothershipToolTitle: tool.serverName ? `${tool.serverName}: ${tool.name}` : tool.name, + serverId: tool.serverId, + toolName: tool.name, + }, + } +} + +function dedupeMcpTools(tools: ToolSchema[]): ToolSchema[] { + const seen = new Set() + return tools.filter((tool) => { + if (seen.has(tool.name)) return false + seen.add(tool.name) + return true + }) +} + +async function discoverServerTools( + userId: string, + workspaceId: string, + serverId: string +): Promise { + try { + const { mcpService } = await import('@/lib/mcp/service') + return await mcpService.discoverServerTools(userId, serverId, workspaceId) + } catch (error) { + logger.warn('Failed to resolve tagged MCP server tools', { + serverId, + workspaceId, + error: toError(error).message, + }) + return [] + } +} + +/** + * Resolves every tool from explicitly tagged MCP servers into request-local, + * deferred tool schemas. Untagged workspace servers are never inspected. + */ +export async function buildTaggedMcpToolSchemas( + userId: string, + workspaceId: string, + serverIds: string[] +): Promise { + const uniqueServerIds = [...new Set(serverIds.filter(Boolean))] + if (uniqueServerIds.length === 0) return [] + + await validateMcpToolsAllowed(userId, workspaceId) + const discovered = await Promise.all( + uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId)) + ) + return dedupeMcpTools(discovered.flat().map(toMothershipMcpTool)) +} + +/** + * Resolves the individual MCP tools selected on a Mothership block. Cached + * editor schemas are used directly; legacy selections without a schema fall + * back to one discovery call per selected server. + */ +export async function buildSelectedMcpToolSchemas( + userId: string, + workspaceId: string, + selections: ToolInput[] +): Promise { + const selected = selections.filter( + (tool) => + tool.type === 'mcp' && + (tool.usageControl || 'auto') !== 'none' && + typeof tool.params?.serverId === 'string' && + typeof tool.params?.toolName === 'string' + ) + if (selected.length === 0) return [] + + await validateMcpToolsAllowed(userId, workspaceId) + const discoveredByServer = new Map>() + const resolved = await Promise.all( + selected.map(async (selection) => { + const serverId = selection.params!.serverId as string + const toolName = selection.params!.toolName as string + const serverName = + typeof selection.params!.serverName === 'string' + ? (selection.params!.serverName as string) + : undefined + + if (selection.schema && typeof selection.schema === 'object') { + return toMothershipMcpTool({ + serverId, + serverName, + name: toolName, + description: + typeof selection.schema.description === 'string' + ? selection.schema.description + : undefined, + inputSchema: selection.schema as Record, + }) + } + + let discovery = discoveredByServer.get(serverId) + if (!discovery) { + discovery = discoverServerTools(userId, workspaceId, serverId) + discoveredByServer.set(serverId, discovery) + } + const match = (await discovery).find((tool) => tool.name === toolName) + return match ? toMothershipMcpTool(match) : null + }) + ) + + return dedupeMcpTools(resolved.filter((tool): tool is ToolSchema => tool !== null)) +} diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 7d341238c4e..da5991703eb 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -75,6 +75,7 @@ describe('sse-handlers tool lifecycle', () => { beforeEach(() => { vi.clearAllMocks() + isSimExecuted.mockReturnValue(true) upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) @@ -558,6 +559,103 @@ describe('sse-handlers tool lifecycle', () => { expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1') }) + it('pairs compaction lifecycle events within each scoped subagent lane', async () => { + context.toolCalls.set('parent-A', { + id: 'parent-A', + name: 'workflow', + status: 'executing', + }) + context.toolCalls.set('parent-B', { + id: 'parent-B', + name: 'workflow', + status: 'executing', + }) + const sendCompaction = async ( + kind: 'compaction_start' | 'compaction_done', + parentToolCallId: string, + spanId: string + ) => { + await subAgentHandlers.run( + { + type: MothershipStreamV1EventType.run, + scope: { + lane: 'subagent', + parentToolCallId, + spanId, + parentSpanId: 'main', + agentId: 'superagent', + }, + payload: { kind }, + } as StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + } + + await sendCompaction(MothershipStreamV1RunKind.compaction_start, 'parent-A', 'span-A') + await sendCompaction(MothershipStreamV1RunKind.compaction_start, 'parent-B', 'span-B') + await sendCompaction(MothershipStreamV1RunKind.compaction_done, 'parent-A', 'span-A') + + const compactions = context.contentBlocks.filter( + (block) => block.type === 'tool_call' && block.toolCall?.name === 'context_compaction' + ) + expect(compactions).toHaveLength(2) + + const laneA = compactions.find((block) => block.spanId === 'span-A') + const laneB = compactions.find((block) => block.spanId === 'span-B') + expect(laneA).toEqual( + expect.objectContaining({ + calledBy: 'workflow', + parentToolCallId: 'parent-A', + parentSpanId: 'main', + endedAt: expect.any(Number), + toolCall: expect.objectContaining({ status: MothershipStreamV1ToolOutcome.success }), + }) + ) + expect(laneB?.toolCall?.status).toBe('executing') + + await sendCompaction(MothershipStreamV1RunKind.compaction_done, 'parent-B', 'span-B') + + expect(context.contentBlocks).toHaveLength(2) + expect(laneB?.toolCall?.status).toBe(MothershipStreamV1ToolOutcome.success) + }) + + it('pairs main-lane compaction start and done into one completed block', async () => { + await sseHandlers.run( + { + type: MothershipStreamV1EventType.run, + payload: { kind: MothershipStreamV1RunKind.compaction_start }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false } + ) + const compactionId = context.contentBlocks[0]?.toolCall?.id + + await sseHandlers.run( + { + type: MothershipStreamV1EventType.run, + payload: { kind: MothershipStreamV1RunKind.compaction_done }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false } + ) + + expect(context.contentBlocks).toHaveLength(1) + expect(context.contentBlocks[0]).toEqual( + expect.objectContaining({ + endedAt: expect.any(Number), + toolCall: expect.objectContaining({ + id: compactionId, + name: 'context_compaction', + status: MothershipStreamV1ToolOutcome.success, + }), + }) + ) + }) + it('keeps two concurrent subagent lanes separate for text and thinking', async () => { const send = (parent: string, channel: MothershipStreamV1TextChannel, text: string) => subAgentHandlers.text( @@ -931,6 +1029,127 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('rebinds a gateway call to the resolved integration operation and branded activity', async () => { + isSimExecuted.mockReturnValue(false) + executeTool.mockResolvedValueOnce({ success: true, output: { emails: [] } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-gmail', + toolName: 'call_integration_tool', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + partial: true, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.toolCalls.get('gateway-gmail')).toEqual( + expect.objectContaining({ + name: 'call_integration_tool', + displayTitle: 'Calling integration', + }) + ) + + for (const argumentsDelta of [ + '{"toolId":"gmail_read_v2",', + '"description":"Searching for invoice emails",', + ]) { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-gmail', + toolName: 'call_integration_tool', + argumentsDelta, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.args_delta, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + } + + expect(context.toolCalls.get('gateway-gmail')).toEqual( + expect.objectContaining({ + name: 'call_integration_tool', + displayTitle: 'Searching for invoice emails', + streamingArgs: '{"toolId":"gmail_read_v2","description":"Searching for invoice emails",', + }) + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-gmail', + toolName: 'call_integration_tool', + arguments: { + toolId: 'gmail_read_v2', + description: 'Searching for invoice emails', + arguments: { maxResults: 10 }, + }, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.toolCalls.get('gateway-gmail')).toEqual( + expect.objectContaining({ + name: 'call_integration_tool', + displayTitle: 'Searching for invoice emails', + }) + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'gateway-gmail', + toolName: 'gmail_read_v2', + arguments: { maxResults: 10, credentialId: 'cred-gmail' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledTimes(1) + expect(executeTool).toHaveBeenCalledWith( + 'gmail_read_v2', + { maxResults: 10, credentialId: 'cred-gmail' }, + expect.any(Object) + ) + expect(context.toolCalls.get('gateway-gmail')).toEqual( + expect.objectContaining({ + name: 'gmail_read_v2', + displayTitle: 'Searching for invoice emails', + params: { maxResults: 10, credentialId: 'cred-gmail' }, + }) + ) + }) + it('clears pending continuation state when a run resumes', async () => { context.awaitingAsyncContinuation = { checkpointId: 'cp-1', diff --git a/apps/sim/lib/copilot/request/handlers/index.ts b/apps/sim/lib/copilot/request/handlers/index.ts index b170f9104b8..554ae149599 100644 --- a/apps/sim/lib/copilot/request/handlers/index.ts +++ b/apps/sim/lib/copilot/request/handlers/index.ts @@ -30,6 +30,7 @@ export const sseHandlers: Record = { export const subAgentHandlers: Record = { [MothershipStreamV1EventType.text]: handleTextEvent('subagent'), [MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'subagent'), + [MothershipStreamV1EventType.run]: handleRunEvent, [MothershipStreamV1EventType.span]: handleSpanEvent, } diff --git a/apps/sim/lib/copilot/request/handlers/run.ts b/apps/sim/lib/copilot/request/handlers/run.ts index 593eecce536..9f162be0b7d 100644 --- a/apps/sim/lib/copilot/request/handlers/run.ts +++ b/apps/sim/lib/copilot/request/handlers/run.ts @@ -1,12 +1,69 @@ import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import type { ContentBlock, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler } from './types' -import { addContentBlock } from './types' +import { addContentBlock, getScopedSpanIdentity } from './types' const logger = createLogger('CopilotRunHandler') +const CONTEXT_COMPACTION_TOOL = 'context_compaction' + +function isSameCompactionLane(block: ContentBlock, event: StreamEvent): boolean { + const spanId = event.scope?.spanId + if (spanId) return block.spanId === spanId + + const parentToolCallId = event.scope?.parentToolCallId + if (parentToolCallId) return block.parentToolCallId === parentToolCallId + + return !block.spanId && !block.parentToolCallId && !block.calledBy +} + +function findActiveCompactionBlock( + context: StreamingContext, + event: StreamEvent +): ContentBlock | undefined { + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const block = context.contentBlocks[i] + if ( + block.type === 'tool_call' && + block.toolCall?.name === CONTEXT_COMPACTION_TOOL && + block.toolCall.status === 'executing' && + isSameCompactionLane(block, event) + ) { + return block + } + } + return undefined +} + +function addCompactionBlock( + context: StreamingContext, + event: StreamEvent, + status: 'executing' | typeof MothershipStreamV1ToolOutcome.success +): void { + const now = Date.now() + const parentToolCallId = event.scope?.parentToolCallId + const calledBy = + (parentToolCallId ? context.toolCalls.get(parentToolCallId)?.name : undefined) ?? + event.scope?.agentId + addContentBlock(context, { + type: 'tool_call', + toolCall: { + id: `compaction-${generateShortId()}`, + name: CONTEXT_COMPACTION_TOOL, + status, + startTime: now, + ...(status === MothershipStreamV1ToolOutcome.success ? { endTime: now } : {}), + }, + ...(calledBy ? { calledBy } : {}), + ...(parentToolCallId ? { parentToolCallId } : {}), + ...getScopedSpanIdentity(event), + ...(status === MothershipStreamV1ToolOutcome.success ? { endedAt: now } : {}), + }) +} export const handleRunEvent: StreamHandler = (event, context) => { if (event.type !== 'run') { @@ -42,14 +99,9 @@ export const handleRunEvent: StreamHandler = (event, context) => { } if (event.payload.kind === MothershipStreamV1RunKind.compaction_start) { - addContentBlock(context, { - type: 'tool_call', - toolCall: { - id: `compaction-${Date.now()}`, - name: 'context_compaction', - status: 'executing', - }, - }) + if (!findActiveCompactionBlock(context, event)) { + addCompactionBlock(context, event, 'executing') + } return } @@ -61,13 +113,15 @@ export const handleRunEvent: StreamHandler = (event, context) => { } if (event.payload.kind === MothershipStreamV1RunKind.compaction_done) { - addContentBlock(context, { - type: 'tool_call', - toolCall: { - id: `compaction-${Date.now()}`, - name: 'context_compaction', - status: MothershipStreamV1ToolOutcome.success, - }, - }) + const active = findActiveCompactionBlock(context, event) + if (!active?.toolCall) { + addCompactionBlock(context, event, MothershipStreamV1ToolOutcome.success) + return + } + + const now = Date.now() + active.toolCall.status = MothershipStreamV1ToolOutcome.success + active.toolCall.endTime = now + active.endedAt = now } } diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 16fbc9b6ead..8f110a82b28 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -33,6 +33,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { type: 'subagent_thinking', content: '', parentToolCallId, + ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, timestamp: Date.now(), } @@ -53,6 +54,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { type: 'subagent_text', content: chunk, parentToolCallId, + ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, }) return diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index be028b2b1fc..0e30aaee0b2 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -30,8 +30,10 @@ import type { } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { abortPendingToolIfStreamDead, @@ -53,12 +55,91 @@ const logger = createLogger('CopilotToolHandler') function applyToolDisplay(toolCall: ToolCallState | undefined): void { if (!toolCall?.name) return + // Integration rows show only the model-authored activity phrase; the trusted + // integration branding is the icon, derived client-side from the operation + // name (or streamed toolId) via the block registry. With no description yet, + // fall back to the integration name so the humanized gateway name never + // renders. + if (toolCall.name === INTEGRATION_GATEWAY_TOOL) { + const toolId = toolCall.params?.toolId + const description = toolCall.params?.description + if (typeof description === 'string' && description.trim()) { + toolCall.displayTitle = description.trim() + return + } + if (typeof toolId === 'string') { + const integration = getBlockByToolName(toolId) + if (integration) { + toolCall.displayTitle = integration.name + return + } + } + } + if (toolCall.integrationDescription) { + toolCall.displayTitle = toolCall.integrationDescription + return + } toolCall.displayTitle = getToolDisplayTitle( toolCall.name, toolCall.params as Record | undefined ) } +const INTEGRATION_GATEWAY_TOOL = 'call_integration_tool' + +function handleToolArgsDelta( + data: { argumentsDelta: string; toolCallId: string; toolName: string }, + context: StreamingContext +): void { + const toolCall = context.toolCalls.get(data.toolCallId) + if (!toolCall) return + toolCall.streamingArgs = `${toolCall.streamingArgs ?? ''}${data.argumentsDelta}` + if (toolCall.name !== INTEGRATION_GATEWAY_TOOL) return + + const toolId = extractStreamingStringArgument(toolCall.streamingArgs, 'toolId') + const description = extractStreamingStringArgument(toolCall.streamingArgs, 'description') + if (toolId || description) { + toolCall.params = { + ...toolCall.params, + ...(toolId ? { toolId } : {}), + ...(description ? { description } : {}), + } + applyToolDisplay(toolCall) + } +} + +/** + * The model first streams the stable gateway call. Once Go resolves its exact + * server-owned operation, a second authoritative frame with the same call id + * carries the real executable tool name and arguments. Rebind atomically so + * execution, persistence, branding, and results all use that operation while + * retaining only the model-authored activity description for presentation. + */ +function rebindResolvedIntegrationCall( + toolCall: ToolCallState | undefined, + toolName: string, + args: Record | undefined +): boolean { + if (!toolCall || toolCall.name !== INTEGRATION_GATEWAY_TOOL) { + return false + } + // Gateway arguments may arrive over several generating/final frames. Keep + // the newest complete snapshot so the eventual authoritative operation frame + // can retain the model-authored presentation description. + if (toolName === INTEGRATION_GATEWAY_TOOL) { + if (args) toolCall.params = args + return true + } + const description = toolCall.params?.description + if (typeof description === 'string' && description.trim()) { + toolCall.integrationDescription = description.trim() + } + toolCall.name = toolName + toolCall.params = args + applyToolDisplay(toolCall) + return true +} + /** * Upsert the durable `async_tool_calls` row before the authoritative tool-call * SSE frame is forwarded to the client, so `/api/copilot/confirm` can never @@ -131,6 +212,7 @@ export async function handleToolEvent( } if (isToolArgsDeltaStreamEvent(event)) { + handleToolArgsDelta(event.payload, context) return } @@ -260,8 +342,10 @@ async function handleCallPhase( if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + if (existing && !existing.name && toolName) existing.name = toolName + if (existing && !existing.params && args) existing.params = args + } applyToolDisplay(existing) return } @@ -270,8 +354,10 @@ async function handleCallPhase( existing?.endTime || (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + if (!existing.name && toolName) existing.name = toolName + if (!existing.params && args) existing.params = args + } applyToolDisplay(existing) return } @@ -375,8 +461,10 @@ function registerSubagentToolCall( const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args + if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { + if (!toolCall.name && toolName) toolCall.name = toolName + if (args && !toolCall.params) toolCall.params = args + } applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) } else { @@ -404,8 +492,10 @@ function registerSubagentToolCall( const subagentToolCalls = context.subAgentToolCalls[parentToolCallId] const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { + if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName + if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + } applyToolDisplay(existingSubagentToolCall) } else { subagentToolCalls.push(toolCall) @@ -422,7 +512,9 @@ function registerMainToolCall( ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (args && !existing.params) existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { + existing.params = args + } applyToolDisplay(existing) if (hideFromUi) { removeToolCallContentBlock(context, toolCallId) diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts index 585cee9d18c..65b5b4319c4 100644 --- a/apps/sim/lib/copilot/request/sse-utils.test.ts +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -40,6 +40,23 @@ describe('shouldSkipToolCallEvent', () => { ) ).toBe(false) }) + + it('allows a gateway call id to rebind to its resolved integration operation', () => { + const callId = 'gateway-resolve-call' + const gateway = toolCallEvent(callId, 'call_integration_tool', { + toolId: 'gmail_read_v2', + description: 'Reading recent emails', + arguments: {}, + }) + const resolved = toolCallEvent(callId, 'gmail_read_v2', { + credentialId: 'cred-gmail', + }) + + expect(shouldSkipToolCallEvent(gateway)).toBe(false) + expect(shouldSkipToolCallEvent(gateway)).toBe(true) + expect(shouldSkipToolCallEvent(resolved)).toBe(false) + expect(shouldSkipToolCallEvent(resolved)).toBe(true) + }) }) function toolCallEvent( diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/copilot/request/sse-utils.ts index 310c1b9eaa8..47fa1f5b549 100644 --- a/apps/sim/lib/copilot/request/sse-utils.ts +++ b/apps/sim/lib/copilot/request/sse-utils.ts @@ -37,12 +37,16 @@ function getToolCallIdFromResultEvent(event: ToolResultStreamEvent): string { return event.payload.toolCallId } -function markToolCallSeen(toolCallId: string): void { - addToSet(seenToolCalls, toolCallId) +function toolCallDedupeKey(toolCallId: string, toolName: string): string { + return `${toolCallId}\u0000${toolName}` } -function wasToolCallSeen(toolCallId: string): boolean { - return seenToolCalls.has(toolCallId) +function markToolCallSeen(toolCallId: string, toolName: string): void { + addToSet(seenToolCalls, toolCallDedupeKey(toolCallId, toolName)) +} + +function wasToolCallSeen(toolCallId: string, toolName: string): boolean { + return seenToolCalls.has(toolCallDedupeKey(toolCallId, toolName)) } export function markToolResultSeen(toolCallId: string): void { @@ -59,8 +63,13 @@ export function shouldSkipToolCallEvent(event: StreamEvent): boolean { if (event.payload.status === TOOL_CALL_STATUS.generating) return false const toolCallId = getToolCallIdFromCallEvent(event) if (event.payload.partial === true) return false - if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) return true - markToolCallSeen(toolCallId) + const toolName = event.payload.toolName + // A resolved integration gateway intentionally emits two authoritative + // call frames under one provider call ID: first call_integration_tool, then + // the exact request-local operation. Deduplicate retransmits of the same + // frame, but allow the name transition through to execution and UI rebinding. + if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId, toolName)) return true + markToolCallSeen(toolCallId, toolName) return false } diff --git a/apps/sim/lib/copilot/request/tool-call-state.test.ts b/apps/sim/lib/copilot/request/tool-call-state.test.ts new file mode 100644 index 00000000000..7f5c3dd008a --- /dev/null +++ b/apps/sim/lib/copilot/request/tool-call-state.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import type { ToolCallState } from '@/lib/copilot/request/types' +import { getToolCallTerminalData } from './tool-call-state' + +describe('getToolCallTerminalData', () => { + it('reduces a successful generate_api_key result to only its status message', () => { + const tool: ToolCallState = { + id: 't1', + name: 'generate_api_key', + status: MothershipStreamV1ToolOutcome.success, + result: { + success: true, + output: { + id: 'k1', + name: 'prod', + key: 'sk-sim-secret-value', + message: 'API key "prod" created.', + }, + }, + } + + const data = getToolCallTerminalData(tool) + + // The model gets only the status message — no key, no id/name/workspaceId. + expect(data).toBe('API key "prod" created.') + expect(JSON.stringify(data)).not.toContain('sk-sim-secret-value') + expect(JSON.stringify(data)).not.toContain('k1') + }) + + it('passes through other tools output unchanged', () => { + const tool: ToolCallState = { + id: 't2', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + } + + expect(getToolCallTerminalData(tool)).toEqual({ content: 'file contents' }) + }) + + it('surfaces the error for a failed generate_api_key without inventing a key', () => { + const tool: ToolCallState = { + id: 't3', + name: 'generate_api_key', + status: MothershipStreamV1ToolOutcome.error, + error: 'name is required', + } + + expect(getToolCallTerminalData(tool)).toEqual({ error: 'name is required' }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index 70433429043..e5f1b833bff 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -1,3 +1,4 @@ +import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolOutcome as TerminalToolCallStatus, @@ -57,17 +58,43 @@ export function requireToolCallError( } export function getToolCallTerminalData( + toolCall: Pick +): unknown { + // getToolCallTerminalData is the single producer of "what the model reads on + // resume", so it is where a tool's result is reduced to its model-facing form — + // e.g. generate_api_key yields only its status message; the generated key never + // reaches the model (it goes to the browser via the SSE tool result instead). + return toolResultForModel(toolCall.name, getToolCallTerminalDataRaw(toolCall)) +} + +function getToolCallTerminalDataRaw( toolCall: Pick ): unknown { const output = getToolCallStateOutput(toolCall) + const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus) + if (output !== undefined) { - return output + if (!failed) { + return output + } + /** + * A failed call must always surface its error in the terminal data — this + * is what the model reads on resume. Handlers can fail with an + * empty-but-defined output (the app-tool executor's "Tool not found" ships + * `output: {}`), and preferring that output rendered failures as bare `{}`, + * so the model retried blind instead of reacting to the error. + */ + const error = + typeof toolCall.error === 'string' && toolCall.error.length > 0 + ? toolCall.error + : 'Tool failed without an error message' + if (output && typeof output === 'object' && !Array.isArray(output)) { + return 'error' in output ? output : { ...output, error } + } + return { output, error } } - if ( - toolCall.status === MothershipStreamV1ToolOutcome.success || - toolCall.status === MothershipStreamV1ToolOutcome.skipped - ) { + if (!failed) { return undefined } diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts new file mode 100644 index 00000000000..789f8165fd9 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -0,0 +1,15 @@ +import '@sim/testing/mocks/executor' + +import { describe, expect, it } from 'vitest' +import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' +import { toolWatchdogTimeoutMs } from '@/lib/copilot/request/tools/executor' + +describe('toolWatchdogTimeoutMs', () => { + it('gives request-scoped MCP tools the long-running watchdog', () => { + expect(toolWatchdogTimeoutMs('mcp-363de040-web_search_exa')).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS) + }) + + it('keeps ordinary tools on the strict default watchdog', () => { + expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index bb52291515c..bdb90c3cf48 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,12 +33,13 @@ import { KnowledgeBase, MaterializeFile, Media, - Research, Run, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -47,7 +48,6 @@ import { recordSimToolMetric } from '@/lib/copilot/request/metrics' import { withCopilotToolSpan } from '@/lib/copilot/request/otel' import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { - getToolCallStateOutput, getToolCallTerminalData, requireToolCallError, setTerminalToolCallState, @@ -68,6 +68,7 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -207,12 +208,13 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunWorkflow.id, RunWorkflowUntilBlock.id, FunctionExecute.id, + RunCode.id, GenerateImage.id, GenerateAudio.id, GenerateVideo.id, Ffmpeg.id, Media.id, - Research.id, + Search.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, @@ -223,7 +225,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ ]) export function toolWatchdogTimeoutMs(toolName: string | undefined): number { - return toolName && LONG_RUNNING_TOOL_IDS.has(toolName) + return toolName && (LONG_RUNNING_TOOL_IDS.has(toolName) || isMcpTool(toolName)) ? TOOL_WATCHDOG_LONG_RUNNING_MS : TOOL_WATCHDOG_DEFAULT_MS } @@ -329,7 +331,10 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.success) { - const data = getToolCallStateOutput(toolCall) + // getToolCallTerminalData (not raw output) so the completion signal carries + // the model-facing/redacted result — keeps the sim_key out of every path + // that consumes a completion, matching the error branch below. + const data = getToolCallTerminalData(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool completed', @@ -338,7 +343,7 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) { - const data = getToolCallStateOutput(toolCall) + const data = getToolCallTerminalData(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool skipped', @@ -649,7 +654,10 @@ async function executeToolAndReportInner( }) if (result.success) { - const raw = result.output + // Log the model-facing (redacted) view, not result.output — for + // generate_api_key the raw output carries the plaintext key, which must + // never reach application logs. + const raw = getToolCallTerminalData(toolCall) const preview = typeof raw === 'string' ? raw.slice(0, 200) diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index ee77129e269..139757524c5 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -164,6 +164,34 @@ describe('maybeWriteOutputToFile', () => { expect(result.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) }) + + it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => { + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, + { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, + buildContext({ workspaceId: undefined }) + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('NOT written') + // The computed value survives so the model can use it without re-running. + expect(result.output).toEqual({ result: 'name,age\nAlice,30', stdout: '' }) + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('still passes results through untouched when no outputs are declared, even without workspace context', async () => { + const original = { success: true, output: { result: 42, stdout: '' } } + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + {}, + original, + buildContext({ workspaceId: undefined }) + ) + + expect(result).toBe(original) + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) }) describe('extractTabularData', () => { diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 6a2811a6707..5fe59ef72a2 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -206,11 +206,27 @@ export async function maybeWriteOutputToFile( ): Promise { if (!result.success || !result.output) return result if (!OUTPUT_PATH_TOOLS.has(toolName)) return result - if (!context.workspaceId || !context.userId) return result const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) if (outputFiles.length === 0) return result + // The tool declared workspace file outputs; passing the successful result + // through without writing them would be a silent no-op the model reads as + // "file written", so fail loudly instead — but keep the computed output so + // the model can still use the value without re-running the tool. + if (!context.workspaceId || !context.userId) { + logger.warn('Failing tool result: declared output files but no workspace context', { + toolName, + outputCount: outputFiles.length, + }) + return { + success: false, + error: + 'Declared output file(s) were NOT written: this tool call has no workspace context. The computed result is included in the output, but it was not saved to any file.', + output: result.output, + } + } + const outputObject = result.output && typeof result.output === 'object' && !Array.isArray(result.output) ? (result.output as Record) diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 2efa49fdbcc..406ef9f0953 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -23,6 +23,10 @@ export interface ToolCallState { name: string status: ToolCallStatus displayTitle?: string + /** Model-authored activity text for a gateway-resolved integration call. */ + integrationDescription?: string + /** Accumulated partial JSON of the arguments while the model streams them. */ + streamingArgs?: string params?: Record result?: ToolCallStateResult error?: string @@ -65,6 +69,12 @@ export interface ContentBlock { timestamp: number endedAt?: number parentToolCallId?: string + /** + * Subagent name for lane blocks (from the event scope's agentId). Persisted + * so a reloaded transcript can rebuild the lane's group even when the + * `subagent` start block is missing (resume legs re-emit text without start). + */ + subagent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index e2e381f3f31..ff97f23cc6f 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -181,6 +181,90 @@ describe('extractResourcesFromToolResult', () => { }) describe('extractDeletedResourcesFromToolResult', () => { + it('extracts every successfully deleted workflow from the batch result', () => { + const resources = extractDeletedResourcesFromToolResult( + 'delete_workflow', + { workflowIds: ['wf-1', 'wf-2', 'wf-failed'] }, + { + deleted: [ + { workflowId: 'wf-1', name: 'First workflow' }, + { workflowId: 'wf-2', name: 'Second workflow' }, + ], + failed: ['wf-failed'], + } + ) + + expect(resources).toEqual([ + { type: 'workflow', id: 'wf-1', title: 'First workflow' }, + { type: 'workflow', id: 'wf-2', title: 'Second workflow' }, + ]) + }) + + it('extracts deleted files from delete_file result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'delete_file', + { paths: ['files/one.md', 'files/two.md'] }, + { + success: true, + data: { + deleted: [ + { id: 'file-1', name: 'one.md' }, + { id: 'file-2', name: 'two.md' }, + ], + failed: [], + }, + } + ) + ).toEqual([ + { type: 'file', id: 'file-1', title: 'one.md' }, + { type: 'file', id: 'file-2', title: 'two.md' }, + ]) + }) + + it('extracts deleted file folders from delete_file_folder result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'delete_file_folder', + { paths: ['files/Archive'] }, + { success: true, data: { folders: 1, files: 2, deletedFolderIds: ['folder-1'] } } + ) + ).toEqual([{ type: 'filefolder', id: 'folder-1', title: 'Folder' }]) + }) + + it('extracts only successfully deleted tables from user_table result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'user_table', + { operation: 'delete', args: { tableIds: ['table-1', 'table-failed'] } }, + { success: true, data: { deleted: ['table-1'], failed: ['table-failed'] } } + ) + ).toEqual([{ type: 'table', id: 'table-1', title: 'Table' }]) + }) + + it('extracts deleted knowledge bases from knowledge_base result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'knowledge_base', + { operation: 'delete', args: { knowledgeBaseIds: ['kb-1'] } }, + { + success: true, + data: { deleted: [{ id: 'kb-1', name: 'Docs' }], notFound: [] }, + } + ) + ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) + }) + + it('extracts deleted workflow folders from manage_folder delete results', () => { + expect( + extractDeletedResourcesFromToolResult( + 'manage_folder', + { operation: 'delete', folderId: 'folder-1' }, + { deleted: ['folder-1'], failed: [] } + ) + ).toEqual([{ type: 'folder', id: 'folder-1', title: 'Folder' }]) + }) + it('removes scheduledtask resources on manage_scheduled_task delete', () => { const resources = extractDeletedResourcesFromToolResult( 'manage_scheduled_task', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index d9e311f4720..7874718ef5b 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,6 +1,8 @@ import { CreateFile, CreateWorkflow, + DeleteFile, + DeleteFileFolder, DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, @@ -11,6 +13,7 @@ import { GenerateVideo, Knowledge, KnowledgeBase, + ManageFolder, ManageScheduledTask, UserTable, WorkspaceFile, @@ -241,9 +244,12 @@ export function extractResourcesFromToolResult( const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { [DeleteWorkflow.id]: 'workflow', + [DeleteFile.id]: 'file', + [DeleteFileFolder.id]: 'filefolder', [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', + [ManageFolder.id]: 'folder', [ManageScheduledTask.id]: 'scheduledtask', } @@ -271,8 +277,24 @@ export function extractDeletedResourcesFromToolResult( switch (toolName) { case DeleteWorkflow.id: { + const deleted = Array.isArray(result.deleted) ? result.deleted : [] + const resources = deleted.flatMap((entry): ChatResource[] => { + const deletedWorkflow = asRecord(entry) + const workflowId = deletedWorkflow.workflowId + if (typeof workflowId !== 'string' || !workflowId) return [] + return [ + { + type: resourceType, + id: workflowId, + title: typeof deletedWorkflow.name === 'string' ? deletedWorkflow.name : 'Workflow', + }, + ] + }) + if (resources.length > 0) return resources + + // Backward compatibility for historical single-workflow tool results. const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted) { + if (workflowId && result.deleted === true) { return [ { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, ] @@ -280,6 +302,31 @@ export function extractDeletedResourcesFromToolResult( return [] } + case DeleteFile.id: { + const deleted = Array.isArray(data.deleted) ? data.deleted : [] + return deleted.flatMap((entry): ChatResource[] => { + const deletedFile = asRecord(entry) + const fileId = deletedFile.id + if (typeof fileId !== 'string' || !fileId) return [] + return [ + { + type: resourceType, + id: fileId, + title: typeof deletedFile.name === 'string' ? deletedFile.name : 'File', + }, + ] + }) + } + + case DeleteFileFolder.id: { + const deletedFolderIds = Array.isArray(data.deletedFolderIds) + ? data.deletedFolderIds.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + : [] + return deletedFolderIds.map((id) => ({ type: resourceType, id, title: 'Folder' })) + } + case WorkspaceFile.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) @@ -292,6 +339,12 @@ export function extractDeletedResourcesFromToolResult( case UserTable.id: { if (operation !== 'delete') return [] + const deleted = Array.isArray(data.deleted) + ? data.deleted.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + if (deleted.length > 0) { + return deleted.map((id) => ({ type: resourceType, id, title: 'Table' })) + } const tableId = (args.tableId as string) ?? (params?.tableId as string) if (tableId) { return [{ type: resourceType, id: tableId, title: 'Table' }] @@ -301,6 +354,23 @@ export function extractDeletedResourcesFromToolResult( case KnowledgeBase.id: { if (operation !== 'delete') return [] + const deleted = Array.isArray(data.deleted) ? data.deleted : [] + const resources = deleted.flatMap((entry): ChatResource[] => { + const deletedKnowledgeBase = asRecord(entry) + const knowledgeBaseId = deletedKnowledgeBase.id + if (typeof knowledgeBaseId !== 'string' || !knowledgeBaseId) return [] + return [ + { + type: resourceType, + id: knowledgeBaseId, + title: + typeof deletedKnowledgeBase.name === 'string' + ? deletedKnowledgeBase.name + : 'Knowledge Base', + }, + ] + }) + if (resources.length > 0) return resources const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) if (kbId) { return [{ type: resourceType, id: kbId, title: (data.name as string) || 'Knowledge Base' }] @@ -308,6 +378,14 @@ export function extractDeletedResourcesFromToolResult( return [] } + case ManageFolder.id: { + if (operation !== 'delete') return [] + const deletedIds = Array.isArray(result.deleted) ? (result.deleted as unknown[]) : [] + return deletedIds.flatMap((id): ChatResource[] => + typeof id === 'string' && id ? [{ type: resourceType, id, title: 'Folder' }] : [] + ) + } + case ManageScheduledTask.id: { if (operation !== 'delete') return [] const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 61733f43a95..10e78383c7c 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -119,6 +119,31 @@ describe('copilot tool executor fallback', () => { ) }) + it('converts function_execute timeout before invoking its registered Sim handler', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true, output: { result: 'ok' } }) + registerHandler('function_execute', handler) + + const context = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + await executeTool('function_execute', { code: 'return 1', timeout: 7 }, context) + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'return 1', + timeout: 7000, + }), + context + ) + expect(executeAppTool).not.toHaveBeenCalled() + }) + it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index d6f7d5caae8..476f29c93a2 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -44,6 +44,8 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + const normalizedParams = normalizeToolParams(toolId, params, context) + // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never // reach this point in interactive mode. In headless mode (Mothership block, no browser) there // is no client to delegate to, so fall back to the registered server-side handler when one @@ -52,7 +54,7 @@ export async function executeTool( isKnownTool(toolId) && (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { - const appParams = buildAppToolParams(toolId, params, context) + const appParams = buildAppToolParams(normalizedParams, context) return executeAppTool(toolId, appParams) } @@ -71,7 +73,7 @@ export async function executeTool( } try { - return await handler(params, context) + return await handler(normalizedParams, context) } catch (error) { const message = toError(error).message logger.error('Tool execution failed', { @@ -83,6 +85,33 @@ export async function executeTool( } } +function normalizeToolParams( + toolId: string, + params: Record, + context: ToolExecutionContext +): Record { + if (toolId !== FUNCTION_EXECUTE_TOOL_ID || !context.copilotToolExecution) { + return params + } + + const rawTimeoutSeconds = + params.timeout === undefined || params.timeout === null + ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + : Number(params.timeout) + const timeoutSeconds = + Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 + ? rawTimeoutSeconds + : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + + return { + ...params, + timeout: Math.min( + Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), + DEFAULT_EXECUTION_TIMEOUT_MS + ), + } +} + async function executeToolBatch( toolCalls: ToolCallDescriptor[], context: ToolExecutionContext @@ -109,27 +138,11 @@ async function executeToolBatch( } function buildAppToolParams( - toolId: string, params: Record, context: ToolExecutionContext ): Record { const result = { ...params } - if (toolId === FUNCTION_EXECUTE_TOOL_ID && context.copilotToolExecution) { - const rawTimeoutSeconds = - result.timeout === undefined || result.timeout === null - ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS - : Number(result.timeout) - const timeoutSeconds = - Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 - ? rawTimeoutSeconds - : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS - result.timeout = Math.min( - Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), - DEFAULT_EXECUTION_TIMEOUT_MS - ) - } - if (result.credentialId && !result.credential && !result.oauthCredential) { result.credential = result.credentialId } diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 999dea91b28..7b5c9086716 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, CompleteScheduledTask, + Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, DeleteWorkflow, @@ -33,16 +34,17 @@ import { ManageScheduledTask, ManageSkill, MaterializeFile, - MoveWorkflow, + Mkdir as MkdirTool, + Mv as MvTool, OauthGetAuthLink, OauthRequestAccess, OpenResource, PromoteToLive, Read as ReadTool, Redeploy, - RenameWorkflow, RestoreResource, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -89,7 +91,9 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' +import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, executeDeleteWorkflow, @@ -144,9 +148,12 @@ function buildHandlerMap(): Record { [CreateWorkflow.id]: h(executeCreateWorkflow), [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [RenameWorkflow.id]: h(executeRenameWorkflow), - [MoveWorkflow.id]: h(executeMoveWorkflow), [ManageFolder.id]: h(executeManageFolder), + // rename_workflow / move_workflow were removed from the mothership catalog + // in favor of mv; the executors stay registered under literal names so + // in-flight checkpoints still resume. Delete after the mv release soaks. + rename_workflow: h(executeRenameWorkflow), + move_workflow: h(executeMoveWorkflow), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), @@ -178,6 +185,9 @@ function buildHandlerMap(): Record { [GrepTool.id]: h(executeVfsGrep), [GlobTool.id]: h(executeVfsGlob), [ReadTool.id]: h(executeVfsRead), + [MvTool.id]: h(executeVfsMv), + [CpTool.id]: h(executeVfsCp), + [MkdirTool.id]: h(executeVfsMkdir), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), @@ -191,6 +201,7 @@ function buildHandlerMap(): Record { [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), + [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), } diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts index 627927df4cf..d79f294b71f 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts @@ -12,9 +12,7 @@ describe('isToolHiddenInUi', () => { expect(isToolHiddenInUi('load_agent_skill')).toBe(true) }) - it('does not hide user skill loads, ordinary tools, or undefined', () => { - // load_user_skill renders like the old per-skill loaders so the load is visible. - expect(isToolHiddenInUi('load_user_skill')).toBe(false) + it('does not hide ordinary tools or undefined', () => { expect(isToolHiddenInUi('read')).toBe(false) expect(isToolHiddenInUi(undefined)).toBe(false) }) diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index 5154ad2db1d..dbe06363d7a 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,7 +1,13 @@ // load_agent_skill is retained for historical persisted messages; it is no -// longer emitted now that internal skills autoload. load_user_skill is NOT -// hidden — it renders like the old per-skill loaders so users see the skill load. -const HIDDEN_TOOL_NAMES = new Set(['load_agent_skill', 'load_custom_tool', 'load_integration_tool']) +// longer emitted now that internal skills autoload. +// search_integration_tools is gateway plumbing: the discovery step is not a +// user-meaningful action, only the resolved call_integration_tool row is. +const HIDDEN_TOOL_NAMES = new Set([ + 'load_agent_skill', + 'load_custom_tool', + 'load_integration_tool', + 'search_integration_tools', +]) export function isToolHiddenInUi(toolName: string | undefined): boolean { return !!toolName && HIDDEN_TOOL_NAMES.has(toolName) diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 7391a829ead..2f5d592269e 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -1,6 +1,6 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> @@ -47,22 +47,23 @@ export async function ensureWorkspaceAccess( workspaceId: string, userId: string, level: 'read' | 'write' | 'admin' = 'read' -): Promise { +): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } - if (level === 'read') return + if (level === 'read') return access if (level === 'admin') { if (!access.canAdmin) { throw new Error('Admin access required for this workspace') } - return + return access } if (!access.canWrite) { throw new Error('Write or admin access required for this workspace') } + return access } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index b47614c19c0..58cb5c880ea 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -185,7 +185,6 @@ export async function executeDeployApi( const result = await performFullDeploy({ workflowId, userId: context.userId, - workflowName: workflowRecord.name || undefined, versionDescription, versionName, }) @@ -197,19 +196,24 @@ export async function executeDeployApi( const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) + const isDeployed = Boolean(result.activeDeployment) return { success: true, output: { workflowId, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt, version: result.version, + lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, + readiness: result.latestDeploymentAttempt?.readiness ?? null, + error: result.latestDeploymentAttempt?.error ?? null, + warnings: result.warnings ?? [], apiEndpoint, baseUrl, deploymentType: 'api', deploymentStatus: { api: { - isDeployed: true, + isDeployed, endpoint: apiEndpoint, deployedAt: result.deployedAt, version: result.version, @@ -802,19 +806,24 @@ export async function executeRedeploy( const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) + const isDeployed = Boolean(result.activeDeployment) return { success: true, output: { workflowId, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt || null, version: result.version, + lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, + readiness: result.latestDeploymentAttempt?.readiness ?? null, + error: result.latestDeploymentAttempt?.error ?? null, + warnings: result.warnings ?? [], apiEndpoint, baseUrl, deploymentType: 'api', deploymentStatus: { api: { - isDeployed: true, + isDeployed, endpoint: apiEndpoint, deployedAt: result.deployedAt || null, version: result.version, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index c43c7d37142..66ed0a0d8fc 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -13,6 +13,8 @@ const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() = const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion +const getWorkflowDeploymentSummaryMock = + workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } = vi.hoisted(() => ({ @@ -182,6 +184,17 @@ describe('executePromoteToLive', () => { performActivateVersionMock.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), + latestDeploymentAttempt: { + id: 'op-1', + deploymentVersionId: 'dv-3', + version: 3, + action: 'activate', + status: 'active', + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-30T00:00:00.000Z', + activatedAt: '2026-05-30T00:00:00.000Z', + error: null, + }, }) const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { @@ -194,13 +207,14 @@ describe('executePromoteToLive', () => { workflowId: 'wf-1', version: 3, userId: 'user-1', - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, }) expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', version: 3, message: 'Promoted version 3 to live', + lifecycleStatus: 'active', + error: null, }) }) @@ -322,13 +336,25 @@ describe('executeCheckDeploymentStatus', () => { workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, }) checkNeedsRedeploymentMock.mockResolvedValue(false) + getWorkflowDeploymentSummaryMock.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) }) it('uses the shared redeployment freshness helper for deployed APIs', async () => { + getWorkflowDeploymentSummaryMock.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-1', + version: 1, + deployedAt: '2026-05-28T00:00:00.000Z', + }, + latestDeploymentAttempt: null, + warnings: [], + }) vi.mocked(db.select) - .mockReturnValueOnce( - selectChain([{ isDeployed: true, deployedAt: new Date('2026-05-28') }]) as never - ) + .mockReturnValueOnce(selectChain([{ deployedAt: new Date('2026-05-28') }]) as never) .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain([], true) as never) checkNeedsRedeploymentMock.mockResolvedValueOnce(true) @@ -351,7 +377,7 @@ describe('executeCheckDeploymentStatus', () => { it('does not check redeployment freshness for undeployed APIs', async () => { vi.mocked(db.select) - .mockReturnValueOnce(selectChain([{ isDeployed: false, deployedAt: null }]) as never) + .mockReturnValueOnce(selectChain([{ deployedAt: null }]) as never) .mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain([], true) as never) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index d3c1c3df7db..e1b35503c59 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -9,7 +9,11 @@ import { performUpdateWorkflowMcpServer, } from '@/lib/mcp/orchestration' import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' -import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration' +import { + getWorkflowDeploymentSummary, + performActivateVersion, + performRevertToVersion, +} from '@/lib/workflows/orchestration' import { listWorkflowVersions, updateDeploymentVersionMetadata, @@ -42,9 +46,9 @@ export async function executeCheckDeploymentStatus( const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) const workspaceId = workflowRecord.workspaceId - const [apiDeploy, chatDeploy] = await Promise.all([ + const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([ db - .select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt }) + .select({ deployedAt: workflow.deployedAt }) .from(workflow) .where(eq(workflow.id, workflowId)) .limit(1), @@ -63,9 +67,15 @@ export async function executeCheckDeploymentStatus( .from(chat) .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) .limit(1), + getWorkflowDeploymentSummary(workflowId), ]) - const isApiDeployed = apiDeploy[0]?.isDeployed || false + /** + * Deployed means an active version snapshot exists; the legacy + * `workflow.isDeployed` flag is not consulted so this can never + * contradict the attached `activeDeployment` summary. + */ + const isApiDeployed = deploymentSummary.activeDeployment !== null const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false const apiDetails = { isDeployed: isApiDeployed, @@ -73,6 +83,9 @@ export async function executeCheckDeploymentStatus( endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', needsRedeployment, + activeDeployment: deploymentSummary.activeDeployment, + latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + warnings: deploymentSummary.warnings ?? [], } const isChatDeployed = !!chatDeploy[0] @@ -362,6 +375,7 @@ export async function executeGetDeploymentLog( name: r.name ?? undefined, description: r.description ?? undefined, isActive: r.isActive, + latestOperationStatus: r.latestOperationStatus ?? undefined, createdAt: r.createdAt.toISOString(), createdBy: r.createdBy ?? undefined, })) @@ -539,20 +553,25 @@ export async function executePromoteToLive( workflowId, version, userId: context.userId, - workflow: workflowRecord as Record, }) if (!result.success) { return { success: false, error: result.error || 'Failed to promote version' } } + const isActive = result.latestDeploymentAttempt?.status === 'active' return { success: true, output: { workflowId, version, - message: `Promoted version ${version} to live`, + message: isActive + ? `Promoted version ${version} to live` + : `Started preparing version ${version} for promotion`, deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined, + lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, + readiness: result.latestDeploymentAttempt?.readiness ?? null, + error: result.latestDeploymentAttempt?.error ?? null, warnings: result.warnings, }, } diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index 465da57bc33..0a4763bb39a 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -34,10 +34,11 @@ export async function executeListIntegrationTools( success: true, output: { integration: service, - note: 'Call load_integration_tool({tool_ids: [""]}) with the exact id before invoking an operation.', + note: 'Read the entry\'s "path" verbatim for exact params, then load_integration_tool({tool_ids: [""]}) and call the tool by that exact id.', tools: matches.map((tool) => ({ id: tool.toolId, operation: tool.operation, + path: `components/integrations/${tool.service}/${tool.operation}.json`, name: tool.config.name, description: tool.config.description, })), diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index 17d62baa67d..fb307feb7a4 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -34,7 +34,14 @@ export function executeManageCredential( if (!result.success) { return { success: false, error: result.error || 'Failed to rename credential' } } - return { success: true, output: { credentialId, displayName } } + return { + success: true, + output: { + credentialId, + previousDisplayName: result.previousDisplayName, + displayName, + }, + } } case 'delete': { const ids: string[] = diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 6aa9d0e8a87..59d39906878 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -83,7 +83,10 @@ async function executeSave( .update(workspaceFiles) .set({ context: 'workspace', + // A workspace file has no birth chat or message — clear both provenance + // fields so the row reads as workspace-owned, not stale chat-owned. chatId: null, + messageId: null, originalName: row.displayName ?? row.originalName, size: verifiedSize, }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts new file mode 100644 index 00000000000..30870b9d636 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnsureWorkspaceAccess, mockGetCredentialActorContext } = vi.hoisted(() => ({ + mockEnsureWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: vi.fn(() => [ + { providerId: 'google-email', name: 'Gmail' }, + { providerId: 'slack', name: 'Slack' }, + { providerId: 'trello', name: 'Trello' }, + { providerId: 'shopify', name: 'Shopify' }, + ]), +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' + +const BASE_URL = 'https://sim.test' +const WORKSPACE_ID = 'ws-1' +const USER_ID = 'user-1' +const CREDENTIAL_ID = 'cred-1' + +const context = { + workspaceId: WORKSPACE_ID, + userId: USER_ID, + chatId: 'chat-1', +} as unknown as ExecutionContext + +const WORKSPACE_ACCESS = { + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, +} + +function oauthCredentialActor(overrides: Record = {}) { + return { + credential: { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'google-email', + ...((overrides.credential as Record) ?? {}), + }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), + } +} + +describe('executeOAuthGetAuthLink', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) + }) + + describe('connect (no credentialId)', () => { + it('returns an authorize URL without a credentialId param', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) + + expect(result.success).toBe(true) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('credentialId')).toBeNull() + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + }) + + describe('reconnect (credentialId passed)', () => { + it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(true) + const output = result.output as { oauth_url: string; message: string } + const url = new URL(output.oauth_url) + expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) + expect(output.message).toContain('Reconnect') + expect(output.message).toContain(CREDENTIAL_ID) + }) + + it('reuses the already-resolved workspace access for the credential lookup', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { + workspaceAccess: WORKSPACE_ACCESS, + }) + }) + + it('fails with an agent-visible error for a nonexistent credential', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + credential: null, + member: null, + hasWorkspaceAccess: false, + canWriteWorkspace: false, + isAdmin: false, + }) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: 'cred-hallucinated' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found in this workspace') + }) + + it('fails when the credential belongs to another workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) + ) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found in this workspace') + }) + + it('fails when the credential is not an OAuth credential', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { type: 'env_workspace' } }) + ) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not an OAuth credential') + }) + + it('fails naming the actual provider when providerName does not match the credential', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const result = await executeOAuthGetAuthLink( + { providerName: 'slack', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('google-email') + }) + + it('fails when the caller is not a credential admin', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Admin access') + }) + + it('rejects reconnect for Trello and directs the user to the integrations page', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'trello', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('integrations page') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + + it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'shopify', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('integrations page') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 2dcaabe02a7..e3b55c36bae 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -2,32 +2,44 @@ import { toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialActorContext } from '@/lib/credentials/access' import { getAllOAuthServices } from '@/lib/oauth/utils' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' export async function executeOAuthGetAuthLink( rawParams: Record, context: ExecutionContext ): Promise { const providerName = String(rawParams.providerName || rawParams.provider_name || '') + const rawCredentialId = rawParams.credentialId || rawParams.credential_id + const credentialId = rawCredentialId ? String(rawCredentialId) : undefined const baseUrl = getBaseUrl() try { if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') } - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + const workspaceAccess = await ensureWorkspaceAccess( + context.workspaceId, + context.userId, + 'write' + ) const result = await generateOAuthLink( context.workspaceId, context.workflowId, context.chatId, providerName, - baseUrl + baseUrl, + credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined ) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, output: { - message: `Authorization URL generated for ${result.serviceName}.`, + message: credentialId + ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` + : `Authorization URL generated for ${result.serviceName}.`, oauth_url: result.url, - instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`, + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, provider: result.serviceName, providerId: result.providerId, }, @@ -72,13 +84,20 @@ export async function executeOAuthRequestAccess( * calls Better Auth, so the draft's TTL starts at click and the signed `state` * cookie is planted in the user's browser and the OAuth callback's state check * passes. + * + * When `reconnect` is set, the URL carries the existing credential id so the + * authorize endpoint creates a reconnect draft and the OAuth callback rebinds + * the credential in place instead of creating a new one. Validation happens + * here too (not just at click time) so a bad id fails in the tool result where + * the agent can see it, rather than as a silent browser redirect. */ async function generateOAuthLink( workspaceId: string | undefined, workflowId: string | undefined, chatId: string | undefined, providerName: string, - baseUrl: string + baseUrl: string, + reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } ): Promise<{ url: string; providerId: string; serviceName: string }> { if (!workspaceId) { throw new Error('workspaceId is required to generate an OAuth link') @@ -105,6 +124,39 @@ async function generateOAuthLink( } const { providerId, name: serviceName } = matched + + if (reconnect) { + if (providerId === 'trello' || providerId === 'shopify') { + throw new Error( + `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + + `integrations page and press Reconnect on the credential there.` + ) + } + const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { + workspaceAccess: reconnect.workspaceAccess, + }) + if (!actor.credential || actor.credential.workspaceId !== workspaceId) { + throw new Error( + `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + + `environment/credentials.json for valid credential ids.` + ) + } + if (actor.credential.type !== 'oauth') { + throw new Error( + `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` + ) + } + if (actor.credential.providerId !== providerId) { + throw new Error( + `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + + `not "${providerId}". Pass the matching providerName.` + ) + } + if (!actor.isAdmin) { + throw new Error('Admin access on the credential is required to reconnect it.') + } + } + const callbackURL = workflowId && workspaceId ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` @@ -144,6 +196,9 @@ async function generateOAuthLink( authorizeUrl.searchParams.set('providerId', providerId) authorizeUrl.searchParams.set('workspaceId', workspaceId) authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (reconnect) { + authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) + } return { url: authorizeUrl.toString(), providerId, serviceName } } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts new file mode 100644 index 00000000000..e27babc4512 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -0,0 +1,31 @@ +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' + +/** + * Compute-only variant of function_execute for info-gathering agents: same + * sandbox and inputs, but it must never create or overwrite workspace + * resources. The write vectors (outputs.files, outputTable) are rejected here + * on top of the Go executor's fail-fast guard; run_code is also absent from + * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a + * leaked arg could not write anything. + */ +export async function executeRunCode( + params: Record, + context: ToolExecutionContext +): Promise { + if ('outputs' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', + } + } + if ('outputTable' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + return executeFunctionExecute(params, context) +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts new file mode 100644 index 00000000000..30847fdcc07 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -0,0 +1,509 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkspaceAccess: vi.fn(), + ensureWorkflowAccess: vi.fn(), + getDefaultWorkspaceId: vi.fn(), + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + getWorkspaceFileByName: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + performMoveRenameWorkspaceFile: vi.fn(), + performUpdateWorkspaceFileFolder: vi.fn(), + performCreateFolder: vi.fn(), + performUpdateFolder: vi.fn(), + performUpdateWorkflow: vi.fn(), + duplicateWorkflow: vi.fn(), + listFolders: vi.fn(), + verifyFolderWorkspace: vi.fn(), + listTables: vi.fn(), + renameTable: vi.fn(), + getKnowledgeBases: vi.fn(), + updateKnowledgeBase: vi.fn(), + checkKnowledgeBaseWriteAccess: vi.fn(), + workflowRows: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(mocks.workflowRows()), + }), + }), + }, + workflow: { id: 'id', name: 'name', folderId: 'folderId', workspaceId: 'workspaceId' }, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, + ensureWorkflowAccess: mocks.ensureWorkflowAccess, + getDefaultWorkspaceId: mocks.getDefaultWorkspaceId, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateFolder: mocks.performCreateFolder, + performUpdateFolder: mocks.performUpdateFolder, + performUpdateWorkflow: mocks.performUpdateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/duplicate', () => ({ + duplicateWorkflow: mocks.duplicateWorkflow, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + listFolders: mocks.listFolders, + verifyFolderWorkspace: mocks.verifyFolderWorkspace, +})) + +vi.mock('@/lib/table/service', () => ({ + listTables: mocks.listTables, + renameTable: mocks.renameTable, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mocks.getKnowledgeBases, + updateKnowledgeBase: mocks.updateKnowledgeBase, +})) + +vi.mock('@/app/api/knowledge/utils', () => ({ + checkKnowledgeBaseWriteAccess: mocks.checkKnowledgeBaseWriteAccess, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext + +describe('vfs mv/cp', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) + mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.verifyFolderWorkspace.mockResolvedValue(true) + mocks.listFolders.mockResolvedValue([]) + mocks.workflowRows.mockReturnValue([]) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + }) + + describe('category rules', () => { + it('rejects cross-category moves', async () => { + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'workflows/report' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('across categories') + }) + + it('rejects uploads with a materialize_file pointer', async () => { + const result = await executeVfsMv( + { sources: ['uploads/data.csv'], destination: 'files/data.csv' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('materialize_file') + }) + + it('rejects read-only categories', async () => { + const result = await executeVfsMv( + { sources: ['components/blocks/gmail.json'], destination: 'components/blocks/g.json' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('not a movable resource') + }) + + it('aborts before mutating when the request was cancelled', async () => { + const abortedContext = { + userId: 'user-1', + workspaceId: 'ws-1', + abortSignal: { aborted: true }, + } as unknown as ExecutionContext + const result = await executeVfsMv( + { sources: ['files/a.md'], destination: 'files/b.md' }, + abortedContext + ) + expect(result.success).toBe(false) + expect(result.error).toContain('aborted') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + }) + }) + + describe('files', () => { + it('moves and renames a file in one call, auto-creating destination folders', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/Reports/2026/final.md' }, + context + ) + + expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { + folderId: null, + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + fileId: 'file-1', + targetFolderId: 'ensured-folder', + newName: 'final.md', + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], + }) + }) + + it('moves into an existing folder keeping the name without creating anything', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'a.png' }, + }) + + const result = await executeVfsMv( + { sources: ['files/a.png'], destination: 'files/Images' }, + context + ) + + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + ) + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) + }) + + it('requires a folder destination for multiple sources', async () => { + const result = await executeVfsMv( + { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('must be a folder') + }) + + it('resolves sources at their exact path only — no cross-folder name fallback', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'files/Archive/' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Not found') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('rejects copying workspace files — cp is workflows-only', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'template.md' }) + + const result = await executeVfsCp( + { sources: ['files/template.md'], destination: 'files/Reports/january.md' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('cp only duplicates workflows') + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { + mocks.findWorkspaceFileFolderIdByPath + .mockResolvedValueOnce(null) // destination is not an existing folder + .mockResolvedValueOnce('folder-src') // source resolves as folder + mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ + success: true, + folder: { name: 'Reports 2025' }, + }) + + const result = await executeVfsMv( + { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, + context + ) + + expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + folderId: 'folder-src', + userId: 'user-1', + name: 'Reports 2025', + parentId: 'ensured-folder', + }) + expect(result.success).toBe(true) + }) + + it('rejects reserved alias backing paths', async () => { + const result = await executeVfsMv( + { sources: ['files/.plans/wf_1/launch.md'], destination: 'files/launch.md' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Reserved system paths') + }) + }) + + describe('workflows', () => { + it('renames a workflow at root', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Old Name', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, + context + ) + + expect(mocks.assertWorkflowMutable).toHaveBeenCalledWith('wf-1') + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) + }) + + it('moves a workflow into an existing folder keeping its name', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Archive', parentId: null }, + ]) + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'My Workflow', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, + context + ) + + expect(mocks.assertFolderMutable).toHaveBeenCalledWith('fold-1') + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) + ) + expect(result.success).toBe(true) + }) + + it('surfaces locked-workflow rejections per item', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Locked One', folderId: null }]) + mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) + + const result = await executeVfsMv( + { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + }) + + it('duplicates a workflow with cp (locked source allowed)', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Template', folderId: null }]) + mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) + + const result = await executeVfsCp( + { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, + context + ) + + expect(mocks.assertWorkflowMutable).not.toHaveBeenCalled() + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkflowId: 'wf-1', + workspaceId: 'ws-1', + folderId: null, + name: 'My Copy', + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) + }) + + it('rejects copying workflow folders', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Projects', parentId: null }, + ]) + const result = await executeVfsCp( + { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('moves and renames a workflow folder', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Q1', parentId: null }, + { folderId: 'fold-2', folderName: 'Archive', parentId: null }, + ]) + mocks.performUpdateFolder.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, + context + ) + + expect(mocks.performUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) + ) + expect(result.success).toBe(true) + }) + }) + + describe('mkdir', () => { + it('creates a nested file folder chain', async () => { + const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], + }) + }) + + it('creates a workflow folder via performCreateFolder', async () => { + mocks.listFolders.mockResolvedValue([]) + mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) + + const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) + + expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + name: 'Archive', + parentId: undefined, + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'workflows/Archive', kind: 'workflow_folder', id: 'fold-new' }], + }) + }) + + it('rejects flat namespaces and reserved paths', async () => { + const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, context) + expect(result.success).toBe(false) + expect(result.output).toMatchObject({ + results: [ + { from: 'tables/CRM', error: expect.stringContaining('flat namespace') }, + { from: 'files/.plans/wf_1', error: expect.stringContaining('Reserved') }, + ], + }) + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('rejects creation inside a locked workflow folder', async () => { + mocks.listFolders.mockResolvedValue([]) + mocks.assertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + + const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + expect(mocks.performCreateFolder).not.toHaveBeenCalled() + }) + }) + + describe('tables and knowledge bases (flat namespaces)', () => { + it('renames a table', async () => { + mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) + mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) + + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/Customers' }, + context + ) + + expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) + }) + + it('rejects nested table destinations as flat-namespace violations', async () => { + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('flat namespace') + expect(mocks.renameTable).not.toHaveBeenCalled() + }) + + it('rejects copying tables', async () => { + const result = await executeVfsCp( + { sources: ['tables/Leads'], destination: 'tables/Leads Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('renames a knowledge base after a write-access check', async () => { + mocks.getKnowledgeBases.mockResolvedValue([{ id: 'kb-1', name: 'Docs' }]) + mocks.checkKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true }) + mocks.updateKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Product Docs' }) + + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, + context + ) + + expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') + expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( + 'kb-1', + { name: 'Product Docs' }, + expect.any(String) + ) + expect(result.success).toBe(true) + }) + + it('rejects the reserved knowledgebases/connectors name', async () => { + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('reserved') + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts new file mode 100644 index 00000000000..44682d3e615 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -0,0 +1,768 @@ +import { db, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' +import { toError } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { + ensureWorkflowAccess, + ensureWorkspaceAccess, + getDefaultWorkspaceId, +} from '@/lib/copilot/tools/handlers/access' +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + buildVfsFolderPathMap, + canonicalWorkflowVfsDir, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' +import { generateRequestId } from '@/lib/core/utils/request' +import { getKnowledgeBases, updateKnowledgeBase } from '@/lib/knowledge/service' +import { listTables, renameTable } from '@/lib/table/service' +import { + ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + performCreateFolder, + performUpdateFolder, + performUpdateWorkflow, +} from '@/lib/workflows/orchestration' +import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' +import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' +import { + performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +const logger = createLogger('VfsMutateTools') + +type MutateVerb = 'mv' | 'cp' + +type MutateCategory = 'files' | 'workflows' | 'tables' | 'knowledgebases' + +const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'knowledgebases']) + +const CATEGORY_REJECTIONS: Record = { + uploads: + 'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.', + 'recently-deleted': + 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', +} + +interface VfsMutateOutcome { + from: string + to?: string + kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + id?: string + error?: string +} + +/** Top-level VFS segment of a raw (possibly encoded) path. */ +function topLevelSegment(path: string): string { + return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' +} + +function classifyCategory(path: string): { category: MutateCategory } | { error: string } { + const top = topLevelSegment(path) + if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } + const rejection = CATEGORY_REJECTIONS[top] + if (rejection) return { error: rejection } + return { + error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, + } +} + +function normalizeSources(raw: unknown): string[] { + if (typeof raw === 'string') return raw.trim() ? [raw.trim()] : [] + if (!Array.isArray(raw)) return [] + return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) +} + +function hasTrailingSlash(path: string): boolean { + return /\/\s*$/.test(path) +} + +function assertMutationNotAborted(context: ExecutionContext): void { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before the mutation could be applied.') + } +} + +function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { + const failed = outcomes.filter((o) => o.error) + if (failed.length === outcomes.length) { + return { + success: false, + error: failed[0]?.error || `${verb} failed`, + output: { results: outcomes }, + } + } + return { success: true, output: { results: outcomes } } +} + +export async function executeVfsMv( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('mv', params, context) +} + +export async function executeVfsCp( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('cp', params, context) +} + +/** + * mkdir -p over the VFS: creates each folder path (missing parents included) + * under files/ or workflows/. Existing folders are not an error. + */ +export async function executeVfsMkdir( + params: Record, + context: ExecutionContext +): Promise { + try { + const paths = normalizeSources(params.paths) + if (paths.length === 0) { + return { success: false, error: 'paths is required (an array of folder VFS paths)' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined + + const outcomes: VfsMutateOutcome[] = [] + for (const path of paths) { + const top = topLevelSegment(path) + const segments = decodeVfsPathSegments(path).slice(1) + const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' + + if (top !== 'files' && top !== 'workflows') { + const rejection = + top === 'tables' || top === 'knowledgebases' + ? `${top}/ is a flat namespace with no folders.` + : (CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + outcomes.push({ from: path, kind, error: rejection }) + continue + } + if (segments.length === 0) { + outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) + continue + } + if (top === 'files' && isWorkflowAliasBackingPath(path)) { + outcomes.push({ from: path, kind, error: `Reserved system path: ${path}` }) + continue + } + + try { + assertMutationNotAborted(context) + let folderId: string | null + if (top === 'files') { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }) + } else { + ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( + workspaceId, + context.userId, + await loadWorkflowFolderIndex(workspaceId) + ) + folderId = await ensureWorkflowFolder(segments) + } + outcomes.push({ + from: path, + to: `${top}/${encodeVfsPathSegments(segments)}`, + kind, + id: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ from: path, kind, error: toError(error).message }) + } + } + + return buildResult('mkdir', outcomes) + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +async function executeVfsMutate( + verb: MutateVerb, + params: Record, + context: ExecutionContext +): Promise { + try { + const sources = normalizeSources(params.sources) + const destination = typeof params.destination === 'string' ? params.destination.trim() : '' + if (sources.length === 0) { + return { success: false, error: 'sources is required (an array of canonical VFS paths)' } + } + if (!destination) { + return { success: false, error: 'destination is required' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + const classified = classifyCategory(sources[0]) + if ('error' in classified) return { success: false, error: classified.error } + const { category } = classified + + for (const source of sources.slice(1)) { + const other = classifyCategory(source) + if ('error' in other) return { success: false, error: other.error } + if (other.category !== category) { + return { + success: false, + error: `All sources must share one category; got ${category}/ and ${other.category}/.`, + } + } + } + + const destTop = topLevelSegment(destination) + if (destTop !== category) { + return { + success: false, + error: `Cannot ${verb} across categories: ${category}/ sources cannot target "${destination}". Resources stay within their category.`, + } + } + + switch (category) { + case 'files': + return await mutateWorkspaceFiles(verb, sources, destination, context, workspaceId) + case 'workflows': + return await mutateWorkflows(verb, sources, destination, context, workspaceId) + default: + return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +interface DestinationPlan { + /** True when sources move INTO the destination folder keeping their names. */ + dirMode: boolean + /** Decoded display-name segments of the destination folder. */ + folderSegments: string[] + /** New leaf name; set only when `dirMode` is false. */ + leafName?: string + /** + * Resolve the destination folder id, creating missing folders on first call. + * Deferred and memoized so nothing is created until a source is confirmed + * valid — a fully-failed mv/cp must not leave folders behind. + */ + ensureFolderId: () => Promise +} + +/** + * Shared destination interpretation for every category with folders: an + * existing folder (or a trailing "/") means move/copy INTO it keeping names; + * otherwise the last segment is the new name and the preceding segments are + * the target folder. Folder creation is deferred to `ensureFolderId`. + */ +async function planDestination(args: { + destination: string + sourceCount: number + lookupFolder: (segments: string[]) => Promise + ensureFolderPath: (segments: string[]) => Promise +}): Promise { + const rest = decodeVfsPathSegments(args.destination).slice(1) + const plan = ( + dirMode: boolean, + folderSegments: string[], + leafName?: string, + knownFolderId?: string | null + ): DestinationPlan => { + let memo: Promise | undefined + return { + dirMode, + folderSegments, + leafName, + ensureFolderId: () => + (memo ??= + knownFolderId !== undefined + ? Promise.resolve(knownFolderId) + : folderSegments.length > 0 + ? args.ensureFolderPath(folderSegments) + : Promise.resolve(null)), + } + } + + if (rest.length === 0) return plan(true, [], undefined, null) + if (hasTrailingSlash(args.destination)) return plan(true, rest) + const existing = await args.lookupFolder(rest) + if (existing) return plan(true, rest, undefined, existing) + if (args.sourceCount > 1) { + return { + error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, + } + } + return plan(false, rest.slice(0, -1), rest.at(-1) as string) +} + +/** + * Resolve a `files/...` source to the file at EXACTLY that path (folder- + * anchored). Deliberately not the lenient read-side resolver — on a + * destructive path a bare-name fallback could match a file in a different + * folder than the one named. + */ +async function resolveFileAtExactPath( + workspaceId: string, + segments: string[] +): Promise { + const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') + if (segments.length === 1) { + return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) + if (!folderId) return null + return getWorkspaceFileByName(workspaceId, fileName, { folderId }) +} + +async function mutateWorkspaceFiles( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (verb === 'cp') { + return { + success: false, + error: 'Workspace files cannot be copied — cp only duplicates workflows.', + } + } + for (const path of [...sources, destination]) { + if (isWorkflowAliasBackingPath(path)) { + return { + success: false, + error: `Reserved system paths cannot be moved or renamed: ${path}`, + } + } + } + + const dest = await planDestination({ + destination, + sourceCount: sources.length, + lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), + ensureFolderPath: (segments) => + ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }), + }) + if ('error' in dest) return { success: false, error: dest.error } + + // Resolve every source read-only before mutating anything, so a fully + // invalid call cannot create destination folders as a side effect. + type SourceRef = + | { source: string; file: WorkspaceFileRecord } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a file or folder under files/' }) + continue + } + const file = await resolveFileAtExactPath(workspaceId, segments) + if (file) { + refs.push({ source, file }) + continue + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) + continue + } + + if ('file' in ref) { + assertMutationNotAborted(context) + const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) + const targetFolderId = await dest.ensureFolderId() + const result = await performMoveRenameWorkspaceFile({ + workspaceId, + userId: context.userId, + fileId: ref.file.id, + targetFolderId, + newName: targetName, + }) + outcomes.push( + result.success && result.file + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + } + : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } + ) + continue + } + + assertMutationNotAborted(context) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId: ref.folderId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + outcomes.push( + result.success && result.folder + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + } + : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } + ) + } + + return buildResult(verb, outcomes) +} + +interface WorkflowFolderIndex { + folderPathById: Map + folderIdByPath: Map +} + +async function loadWorkflowFolderIndex(workspaceId: string): Promise { + const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) + const folderIdByPath = new Map() + for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) + return { folderPathById, folderIdByPath } +} + +/** + * mkdir -p for workflow folders: resolves each segment against the index, + * creating missing ones (locked parents rejected) and keeping the index maps + * current so later paths in the same call see the new folders. + */ +function makeWorkflowFolderEnsurer( + workspaceId: string, + userId: string, + index: WorkflowFolderIndex +): (segments: string[]) => Promise { + return async (segments) => { + let parentId: string | null = null + let pathSoFar = '' + for (const segment of segments) { + pathSoFar = pathSoFar + ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` + : encodeVfsPathSegments([segment]) + const existing = index.folderIdByPath.get(pathSoFar) + if (existing) { + parentId = existing + continue + } + await assertFolderMutable(parentId) + const created = await performCreateFolder({ + workspaceId, + userId, + name: segment, + parentId: parentId ?? undefined, + }) + if (!created.success || !created.folder) { + throw new Error(created.error || `Failed to create workflow folder "${segment}"`) + } + index.folderIdByPath.set(pathSoFar, created.folder.id) + index.folderPathById.set(created.folder.id, pathSoFar) + parentId = created.folder.id + } + return parentId + } +} + +async function mutateWorkflows( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const index = await loadWorkflowFolderIndex(workspaceId) + const { folderPathById, folderIdByPath } = index + + const workflowRows = await db + .select({ + id: workflowTable.id, + name: workflowTable.name, + folderId: workflowTable.folderId, + }) + .from(workflowTable) + .where(eq(workflowTable.workspaceId, workspaceId)) + const workflowByPath = new Map() + for (const row of workflowRows) { + const dir = canonicalWorkflowVfsDir({ + name: row.name, + folderPath: row.folderId ? folderPathById.get(row.folderId) : null, + }) + if (!workflowByPath.has(dir)) workflowByPath.set(dir, row) + } + + const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) + + const dest = await planDestination({ + destination, + sourceCount: sources.length, + lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, + ensureFolderPath: ensureWorkflowFolderPath, + }) + if ('error' in dest) return { success: false, error: dest.error } + if (!dest.dirMode && (dest.leafName as string).length > 200) { + return { success: false, error: 'Workflow name must be 200 characters or less' } + } + + // Resolve every source against the in-memory maps before mutating anything. + type SourceRef = + | { source: string; workflow: (typeof workflowRows)[number] } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) + continue + } + const encoded = encodeVfsPathSegments(segments) + const wf = workflowByPath.get(`workflows/${encoded}`) + if (wf) { + refs.push({ source, workflow: wf }) + continue + } + const folderId = folderIdByPath.get(encoded) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) + continue + } + + if ('workflow' in ref) { + const wf = ref.workflow + const targetName = dest.dirMode ? wf.name : (dest.leafName as string) + try { + assertMutationNotAborted(context) + if (verb === 'cp') { + const targetFolderId = await dest.ensureFolderId() + const duplicated = await duplicateWorkflow({ + sourceWorkflowId: wf.id, + userId: context.userId, + workspaceId, + folderId: targetFolderId, + name: targetName, + requestId: generateRequestId(), + }) + outcomes.push({ + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, + kind: 'workflow', + id: duplicated.id, + }) + } else { + await ensureWorkflowAccess(wf.id, context.userId, 'write') + await assertWorkflowMutable(wf.id) + const targetFolderId = await dest.ensureFolderId() + await assertFolderMutable(targetFolderId) + if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { + outcomes.push({ + from: ref.source, + kind: 'workflow', + error: 'Destination folder not found', + }) + continue + } + const result = await performUpdateWorkflow({ + workflowId: wf.id, + userId: context.userId, + workspaceId, + currentName: wf.name, + currentFolderId: wf.folderId, + name: dest.dirMode ? undefined : targetName, + folderId: targetFolderId, + }) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, + kind: 'workflow', + id: wf.id, + } + : { + from: ref.source, + kind: 'workflow', + error: result.error || 'Failed to move workflow', + } + ) + } + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) + } + continue + } + + if (verb === 'cp') { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Workflow folders cannot be copied.', + }) + continue + } + try { + assertMutationNotAborted(context) + await assertFolderMutable(ref.folderId) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + await assertFolderMutable(targetFolderId) + const result = await performUpdateFolder({ + folderId: ref.folderId, + workspaceId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + const finalLeaf = dest.dirMode + ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') + : (dest.leafName as string) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, + kind: 'workflow_folder', + id: ref.folderId, + } + : { + from: ref.source, + kind: 'workflow_folder', + error: result.error || 'Failed to move folder', + } + ) + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) + } + } + + return buildResult(verb, outcomes) +} + +async function renameFlatResource( + verb: MutateVerb, + category: 'tables' | 'knowledgebases', + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const label = category === 'tables' ? 'Tables' : 'Knowledge bases' + const kind = category === 'tables' ? 'table' : 'knowledge_base' + + if (verb === 'cp') { + return { success: false, error: `${label} cannot be copied — duplication is not supported.` } + } + if (sources.length > 1) { + return { success: false, error: `${label} are renamed one at a time.` } + } + + const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) + const destSegments = decodeVfsPathSegments(destination).slice(1) + if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { + return { + success: false, + error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + } + } + + const sourceName = sourceSegments[0] + const newName = destSegments[0] + const canonicalSource = normalizeVfsSegment(sourceName) + + if (category === 'tables') { + const tables = await listTables(workspaceId) + const match = tables.find((t) => normalizeVfsSegment(t.name) === canonicalSource) + if (!match) { + return { success: false, error: `Table not found at ${sources[0]}` } + } + assertMutationNotAborted(context) + const renamed = await renameTable(match.id, newName, generateRequestId()) + return buildResult(verb, [ + { + from: sources[0], + to: `tables/${normalizeVfsSegment(renamed.name)}`, + kind, + id: match.id, + }, + ]) + } + + if (newName.toLowerCase() === 'connectors') { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } + } + const kbs = await getKnowledgeBases(context.userId, workspaceId) + const match = kbs.find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) + if (!match) { + return { success: false, error: `Knowledge base not found at ${sources[0]}` } + } + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + if (!access.hasAccess) { + return { + success: false, + error: `Write access required to rename knowledge base "${match.name}"`, + } + } + assertMutationNotAborted(context) + await updateKnowledgeBase(match.id, { name: newName }, generateRequestId()) + logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) + return buildResult(verb, [ + { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, + ]) +} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 41b62b62099..a690b2d2f0c 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -891,8 +891,7 @@ export async function executeGenerateApiKey( name: result.key.name, key: result.key.key, workspaceId, - message: - 'API key created successfully. Copy this key now — it will not be shown again. Use this key in the x-api-key header when calling workflow API endpoints.', + message: `API key "${result.key.name}" created. You did NOT receive the key value — Sim reveals it to the user ONLY through the secure, copyable chip it renders where you place a {"type":"sim_key"} tag, so you MUST emit that tag now or the user can never see the key (it cannot be shown again). Never print, guess, or fabricate a value. The key authenticates calls to deployed workflow endpoints via the x-api-key header.`, }, } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/files/delete-file.ts b/apps/sim/lib/copilot/tools/server/files/delete-file.ts index 0f2934fd7b4..335d2930e10 100644 --- a/apps/sim/lib/copilot/tools/server/files/delete-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/delete-file.ts @@ -25,6 +25,10 @@ interface DeleteFileArgs { interface DeleteFileResult { success: boolean message: string + data?: { + deleted: { id: string; name: string }[] + failed: string[] + } } export const deleteFileServerTool: BaseServerTool = { @@ -102,6 +106,7 @@ export const deleteFileServerTool: BaseServerTool 0, message: parts.join('. '), + data: { deleted: deletable, failed }, } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 109256e23e4..9236438b1e7 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - CreateFileFolder, - DeleteFileFolder, - ListFileFolders, - MoveFile, - MoveFileFolder, - RenameFileFolder, -} from '@/lib/copilot/generated/tool-catalog-v1' +import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -15,7 +8,9 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { + ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, listWorkspaceFileFolders, @@ -192,7 +187,7 @@ function folderLabel(folder: WorkspaceFileFolderRecord): string { } export const listFileFoldersServerTool: BaseServerTool = { - name: ListFileFolders.id, + name: 'list_file_folders', async execute( params: ListFileFoldersArgs, context?: ServerToolContext @@ -215,7 +210,7 @@ export const listFileFoldersServerTool: BaseServerTool = { - name: CreateFileFolder.id, + name: 'create_file_folder', async execute( params: CreateFileFolderArgs, context?: ServerToolContext @@ -236,22 +231,23 @@ export const createFileFolderServerTool: BaseServerTool 1) { - const resolvedParentId = await findWorkspaceFileFolderIdByPath( + parentId = await ensureWorkspaceFileFolderPath({ workspaceId, - pathSegments.slice(0, -1) - ) - if (!resolvedParentId) { - return { - success: false, - message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, - } - } - parentId = resolvedParentId + userId: context.userId, + pathSegments: pathSegments.slice(0, -1), + }) } assertServerToolNotAborted(context) @@ -285,7 +281,7 @@ export const createFileFolderServerTool: BaseServerTool = { - name: RenameFileFolder.id, + name: 'rename_file_folder', async execute( params: RenameFileFolderArgs, context?: ServerToolContext @@ -341,7 +337,7 @@ export const renameFileFolderServerTool: BaseServerTool = { - name: MoveFileFolder.id, + name: 'move_file_folder', async execute( params: MoveFileFolderArgs, context?: ServerToolContext @@ -441,7 +437,7 @@ export const deleteFileFolderServerTool: BaseServerTool 0 || result.deletedItems.files > 0, message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, - data: result.deletedItems, + data: { ...result.deletedItems, deletedFolderIds: folderIds }, } } catch (error) { return { success: false, message: toError(error).message } @@ -450,7 +446,7 @@ export const deleteFileFolderServerTool: BaseServerTool = { - name: MoveFile.id, + name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index 10bac242eca..a6f9e9f63c8 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { RenameFile } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -31,8 +30,13 @@ interface RenameFileResult { } } +/** + * Removed from the mothership catalog in favor of mv; the executor stays + * registered under its literal name so in-flight checkpoints paused on + * rename_file still resume. Delete after the mv release soaks. + */ export const renameFileServerTool: BaseServerTool = { - name: RenameFile.id, + name: 'rename_file', async execute(params: RenameFileArgs, context?: ServerToolContext): Promise { if (!context?.userId) { throw new Error('Authentication required') diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts new file mode 100644 index 00000000000..8b22ee7d7a4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts @@ -0,0 +1,39 @@ +import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' + +type SearchKnowledgeBaseArgs = { + operation: string + args?: Record +} + +type SearchKnowledgeBaseResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) + +/** + * Read-only variant of knowledge_base for info-gathering agents. Copilot + * access control is a per-agent tool allowlist, so read-only access gets its + * own tool name with its own operation contract — enforced here (where + * execution happens) on top of the fail-fast guard in the Go executor. + */ +export const searchKnowledgeBaseServerTool: BaseServerTool< + SearchKnowledgeBaseArgs, + SearchKnowledgeBaseResult +> = { + name: SearchKnowledgeBase.id, + async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`, + } + } + return knowledgeBaseServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 9dd8c737dde..14afe0f80fe 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -3,7 +3,6 @@ import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateFile, - CreateFileFolder, DeleteFile, DeleteFileFolder, DownloadToWorkspaceFile, @@ -16,10 +15,6 @@ import { ManageCustomTool, ManageMcpTool, ManageSkill, - MoveFile, - MoveFileFolder, - RenameFile, - RenameFileFolder, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -50,10 +45,12 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -130,12 +127,12 @@ const WRITE_ACTIONS: Record = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], - [RenameFile.id]: ['*'], + rename_file: ['*'], [DeleteFile.id]: ['*'], - [MoveFile.id]: ['*'], - [CreateFileFolder.id]: ['*'], - [RenameFileFolder.id]: ['*'], - [MoveFileFolder.id]: ['*'], + move_file: ['*'], + create_file_folder: ['*'], + rename_file_folder: ['*'], + move_file_folder: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], @@ -170,8 +167,10 @@ const baseServerToolRegistry: Record = { [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, + [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, + [queryUserTableServerTool.name]: queryUserTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts new file mode 100644 index 00000000000..e2b07176da5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts @@ -0,0 +1,51 @@ +import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type QueryUserTableArgs = { + operation: string + args?: Record +} + +type QueryUserTableResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) + +/** + * Read-only variant of user_table for info-gathering agents. Copilot access + * control is a per-agent tool allowlist, so read-only access gets its own tool + * name with its own operation contract — enforced here (where execution + * happens) on top of the fail-fast guard in the Go executor. outputPath is + * rejected because query_rows exports rows to a workspace file through it. + */ +export const queryUserTableServerTool: BaseServerTool = { + name: QueryUserTable.id, + async execute(params: QueryUserTableArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, + } + } + if (params?.args && 'outputPath' in params.args) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + if (params && 'outputPath' in (params as Record)) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 540ea5f7d25..5f437d52ba0 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -471,6 +471,7 @@ export const userTableServerTool: BaseServerTool return { success: deleted.length > 0, message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`, + data: { deleted, failed }, } } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts index b17b7ca32b4..77fa6ae5323 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { createBlockFromParams } from './builders' +import { + createBlockFromParams, + normalizeSubblockValue, +} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders' const agentBlockConfig = { type: 'agent', @@ -20,10 +23,25 @@ const conditionBlockConfig = { subBlocks: [{ id: 'conditions', type: 'condition-input' }], } +const knowledgeBlockConfig = { + type: 'knowledge', + name: 'Knowledge', + outputs: {}, + subBlocks: [ + { id: 'tagFilters', type: 'knowledge-tag-filters' }, + { id: 'documentTags', type: 'document-tag-entry' }, + ], +} + +const blocksByType: Record = { + agent: agentBlockConfig, + condition: conditionBlockConfig, + knowledge: knowledgeBlockConfig, +} + vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [agentBlockConfig, conditionBlockConfig], - getBlock: (type: string) => - type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined, + getAllBlocks: () => [agentBlockConfig, conditionBlockConfig, knowledgeBlockConfig], + getBlock: (type: string) => blocksByType[type], })) describe('createBlockFromParams', () => { @@ -69,4 +87,84 @@ describe('createBlockFromParams', () => { expect(parsed[0].id).toBe('condition-1-if') expect(parsed[1].id).toBe('condition-1-else') }) + + it('uses lowercase titles for default condition branches', () => { + const block = createBlockFromParams('condition-1', { + type: 'condition', + name: 'Condition 1', + triggerMode: false, + }) + + const conditions = JSON.parse(block.subBlocks.conditions.value) + expect(conditions.map(({ title }: { title: string }) => title)).toEqual(['if', 'else']) + }) + + it('persists knowledge tag subblocks as JSON strings, not raw arrays', () => { + const block = createBlockFromParams('kb-1', { + type: 'knowledge', + name: 'Knowledge 1', + inputs: { + tagFilters: [{ tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }], + documentTags: [{ tagName: 'Team', tagSlot: 'tag2', value: 'infra' }], + }, + triggerMode: false, + }) + + expect(typeof block.subBlocks.tagFilters.value).toBe('string') + expect(typeof block.subBlocks.documentTags.value).toBe('string') + + const filters = JSON.parse(block.subBlocks.tagFilters.value) + expect(filters[0].tagName).toBe('Department') + expect(filters[0].id).toEqual(expect.any(String)) + }) +}) + +describe('normalizeSubblockValue', () => { + it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])( + 'serializes %s to a JSON string the subblock component can parse', + (key) => { + const result = normalizeSubblockValue(key, [{ id: 'not-a-uuid', title: 'a' }]) + + expect(typeof result).toBe('string') + expect(JSON.parse(result as string)[0].title).toBe('a') + } + ) + + it('accepts a JSON string as input and still returns a string', () => { + const result = normalizeSubblockValue('tagFilters', JSON.stringify([{ tagName: 'Department' }])) + + expect(typeof result).toBe('string') + expect(JSON.parse(result as string)[0].tagName).toBe('Department') + }) + + it('leaves array-with-id subblocks that are not string-serialized as raw arrays', () => { + const result = normalizeSubblockValue('inputFormat', [{ id: 'x', name: 'field' }]) + + expect(Array.isArray(result)).toBe(true) + }) + + it('passes through subblock keys that need no normalization', () => { + expect(normalizeSubblockValue('systemPrompt', 'hello')).toBe('hello') + }) + + // Validation treats null as an explicit clear. Coercing it to "[]" would persist a value + // where the caller asked for none, so the agent reads back an empty filter rather than an + // absent one -- the same absent-vs-empty ambiguity that caused the original data loss. + it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])( + 'passes a null %s through as a clear rather than serializing it to "[]"', + (key) => { + expect(normalizeSubblockValue(key, null)).toBeNull() + expect(normalizeSubblockValue(key, undefined)).toBeUndefined() + } + ) + + it('still serializes an explicitly empty array, which clears the field with a value', () => { + expect(normalizeSubblockValue('tagFilters', [])).toBe('[]') + }) + + it('replaces non-uuid ids so copilot-authored rows match UI-created ones', () => { + const result = normalizeSubblockValue('tagFilters', [{ id: 'filter-1', tagName: 'Department' }]) + + expect(JSON.parse(result as string)[0].id).not.toBe('filter-1') + }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts index f27adb77b4e..286dc576b1d 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts @@ -93,15 +93,7 @@ export function createBlockFromParams( return } - let sanitizedValue = value - - // Normalize array subblocks with id fields (inputFormat, table rows, etc.) - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { - sanitizedValue = JSON.stringify(sanitizedValue) - } - } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue) @@ -161,8 +153,8 @@ export function createBlockFromParams( id: 'conditions', type: 'condition-input', value: JSON.stringify([ - { id: generateId(), title: 'If', value: '' }, - { id: generateId(), title: 'Else', value: '' }, + { id: generateId(), title: 'if', value: '' }, + { id: generateId(), title: 'else', value: '' }, ]), } } else if (params.type === 'router_v2' && !blockState.subBlocks.routes?.value) { @@ -274,29 +266,35 @@ const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([ * Subblock keys whose UI components expect a JSON string, not a raw array. * After normalizeArrayWithIds returns an array, these must be re-stringified. */ -export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes']) +const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes', 'tagFilters', 'documentTags']) + +/** + * Coerces a subblock value to an array, accepting either a raw array or the JSON string + * the string-serialized subblocks persist. + * + * @returns The array, or `null` when the value is not an array and does not parse to one. + * Callers supply their own fallback, which differs by site. + */ +function parseJsonArray(value: unknown): any[] | null { + if (Array.isArray(value)) return value + if (typeof value !== 'string') return null + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} /** * Normalizes array subblock values by ensuring each item has a valid UUID. * The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need * to be converted to proper UUIDs for consistency with UI-created items. */ -export function normalizeArrayWithIds(value: unknown): any[] { - let arr: any[] - - if (Array.isArray(value)) { - arr = value - } else if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - if (!Array.isArray(parsed)) return [] - arr = parsed - } catch { - return [] - } - } else { - return [] - } +function normalizeArrayWithIds(value: unknown): any[] { + const arr = parseJsonArray(value) + if (!arr) return [] return arr.map((item: any) => { if (!item || typeof item !== 'object') { @@ -315,10 +313,30 @@ export function normalizeArrayWithIds(value: unknown): any[] { /** * Checks if a subblock key should have its array items normalized with UUIDs. */ -export function shouldNormalizeArrayIds(key: string): boolean { +function shouldNormalizeArrayIds(key: string): boolean { return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key) } +/** + * Normalizes an array-with-id subblock value, re-serializing it to a JSON string for the + * subblock keys whose UI components read a string rather than a raw array. + * + * Every write path that persists LLM-supplied subblock values must route through this so the + * two concerns cannot drift apart; returns non-array-with-id values untouched. + * + * @remarks + * A nullish value passes through unchanged. `validateValueForSubBlockType` treats null as an + * explicit clear, and coercing it to `"[]"` here would persist a value where the caller asked + * for none -- leaving `sanitizeForCopilot` to show the agent an empty filter rather than an + * absent one, and callers that branch on the field's presence to see it as set. + */ +export function normalizeSubblockValue(key: string, value: unknown): unknown { + if (!shouldNormalizeArrayIds(key)) return value + if (value === null || value === undefined) return value + const normalized = normalizeArrayWithIds(value) + return JSON_STRING_SUBBLOCK_KEYS.has(key) ? JSON.stringify(normalized) : normalized +} + /** * Normalizes condition/router branch IDs to use canonical block-scoped format. * The LLM provides branch structure (if/else-if/else or routes) but should not @@ -327,19 +345,8 @@ export function shouldNormalizeArrayIds(key: string): boolean { export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown { if (key !== 'conditions' && key !== 'routes') return value - let parsed: any[] - if (typeof value === 'string') { - try { - parsed = JSON.parse(value) - if (!Array.isArray(parsed)) return value - } catch { - return value - } - } else if (Array.isArray(value)) { - parsed = value - } else { - return value - } + const parsed = parseJsonArray(value) + if (!parsed) return value let elseIfCounter = 0 const normalized = parsed.map((item, index) => { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index b2e27179d0b..7914f4eaf59 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -221,6 +221,35 @@ describe('handleEditOperation nestedNodes merge', () => { expect(state.blocks['new-agent']).toBeUndefined() }) + it('persists string-serialized subblocks as JSON strings on merged children', () => { + const workflow = makeLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'loop-1', + params: { + nestedNodes: { + 'new-condition': { + type: 'condition', + name: 'Condition 1', + inputs: { + conditions: [ + { id: 'x', title: 'if', value: 'x > 1' }, + { id: 'y', title: 'else', value: '' }, + ], + }, + }, + }, + }, + }, + ]) + + const value = state.blocks['condition-1'].subBlocks.conditions.value + expect(typeof value).toBe('string') + expect(JSON.parse(value as string)[0].title).toBe('if') + }) + it('preserves edges for matched children when connections are not provided', () => { const workflow = makeLoopWorkflow() diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index 8e7e33df487..015791daad1 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -8,12 +8,10 @@ import { applyTriggerConfigToBlockSubblocks, createBlockFromParams, filterDisallowedTools, - JSON_STRING_SUBBLOCK_KEYS, - normalizeArrayWithIds, normalizeConditionRouterIds, normalizeResponseFormat, + normalizeSubblockValue, normalizeTools, - shouldNormalizeArrayIds, updateCanonicalModesForInputs, } from './builders' import type { EditWorkflowOperation, OperationContext } from './types' @@ -209,10 +207,7 @@ function mergeNestedNodesForParent( Object.entries(childValidation.validInputs).forEach(([key, value]) => { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return - let sanitizedValue = value - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) if (key === 'tools' && Array.isArray(value)) { sanitizedValue = filterDisallowedTools( @@ -444,15 +439,7 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } - let sanitizedValue = value - - // Normalize array subblocks with id fields (inputFormat, table rows, etc.) - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { - sanitizedValue = JSON.stringify(sanitizedValue) - } - } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(block_id, key, sanitizedValue) @@ -920,15 +907,7 @@ export function handleInsertIntoSubflowOperation( return } - let sanitizedValue = value - - // Normalize array subblocks with id fields (inputFormat, table rows, etc.) - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { - sanitizedValue = JSON.stringify(sanitizedValue) - } - } + let sanitizedValue = normalizeSubblockValue(key, value) sanitizedValue = normalizeConditionRouterIds(block_id, key, sanitizedValue) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 1a83b20593a..2d18a244180 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -68,7 +68,11 @@ const knowledgeBlockConfig = { type: 'knowledge', name: 'Knowledge', outputs: {}, - subBlocks: [{ id: 'knowledgeBaseId', type: 'knowledge-base-selector' }], + subBlocks: [ + { id: 'knowledgeBaseId', type: 'knowledge-base-selector' }, + { id: 'tagFilters', type: 'knowledge-tag-filters' }, + { id: 'documentTags', type: 'document-tag-entry' }, + ], } const canonicalCredBlockConfig = { @@ -260,6 +264,47 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('expected a JSON array') }) + // Without this guard, normalizeArrayWithIds coerces any unparseable value to [], which the + // write path then persists as "[]" -- silently destroying a tag filter the user configured. + it.each([ + ['a double-encoded JSON string', JSON.stringify(JSON.stringify([{ tagName: 'Department' }]))], + ['an unparseable string', 'not-json'], + ['an object', { tagName: 'Department' }], + ['a number', 5], + ])('rejects knowledge-tag-filters values that are %s', (_label, value) => { + const result = validateInputsForBlock('knowledge', { tagFilters: value }, 'kb-1') + + expect(result.validInputs.tagFilters).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('expected a JSON array') + }) + + it('rejects non-array document-tag-entry values', () => { + const result = validateInputsForBlock('knowledge', { documentTags: 'not-json' }, 'kb-1') + + expect(result.validInputs.documentTags).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('expected a JSON array') + }) + + it.each([ + ['a JSON string array', JSON.stringify([{ tagName: 'Department', tagValue: 'IT' }])], + ['a raw array', [{ tagName: 'Department', tagValue: 'IT' }]], + ['an empty array, clearing the filter', []], + ])('accepts knowledge-tag-filters values that are %s', (_label, value) => { + const result = validateInputsForBlock('knowledge', { tagFilters: value }, 'kb-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.tagFilters).toBeDefined() + }) + + it('accepts a null knowledge-tag-filters value so the field can still be cleared', () => { + const result = validateInputsForBlock('knowledge', { tagFilters: null }, 'kb-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.tagFilters).toBeNull() + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 1bda4606fac..c8c02734c03 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -367,7 +367,9 @@ export function validateValueForSubBlockType( } case 'condition-input': - case 'router-input': { + case 'router-input': + case 'knowledge-tag-filters': + case 'document-tag-entry': { const parsedValue = typeof value === 'string' ? (() => { diff --git a/apps/sim/lib/copilot/tools/streaming-args.ts b/apps/sim/lib/copilot/tools/streaming-args.ts new file mode 100644 index 00000000000..80bfb112245 --- /dev/null +++ b/apps/sim/lib/copilot/tools/streaming-args.ts @@ -0,0 +1,15 @@ +/** Extract a completed JSON string field from an argument buffer that may still be partial. */ +export function extractStreamingStringArgument( + input: string | undefined, + key: string +): string | undefined { + if (!input) return undefined + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = new RegExp(`"${escapedKey}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(input) + if (!match?.[1]) return undefined + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return undefined + } +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 353750417a2..64a66c27ac4 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -6,6 +6,7 @@ import { getToolCompletedTitle, getToolDisplayTitle, humanizeToolName, + mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' describe('humanizeToolName', () => { @@ -56,3 +57,155 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) }) + +describe('mvDisplayVerb', () => { + it('reads a leaf-only change in the same folder as a rename', () => { + expect(mvDisplayVerb('workflows/falling-vacuum', 'workflows/failing-vacuum')).toBe('Renaming') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Reports/b.md')).toBe('Renaming') + expect(mvDisplayVerb('tables/Leads', 'tables/Customers')).toBe('Renaming') + }) + + it('decodes segments so encoded sources compare against plain destinations', () => { + expect(mvDisplayVerb('workflows/My%20Flow', 'workflows/New Flow')).toBe('Renaming') + expect(mvDisplayVerb('files/My%20Docs/a.md', 'files/My Docs/b.md')).toBe('Renaming') + }) + + it('reads parent changes and folder destinations as moves', () => { + expect(mvDisplayVerb('files/a.png', 'files/Images/')).toBe('Moving') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/a.md')).toBe('Moving') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/b.md')).toBe('Moving') + expect(mvDisplayVerb('workflows/My Flow', 'workflows/Archive/')).toBe('Moving') + }) + + it('falls back to Moving when arguments are incomplete', () => { + expect(mvDisplayVerb(undefined, 'files/x.md')).toBe('Moving') + expect(mvDisplayVerb('files/x.md', undefined)).toBe('Moving') + }) +}) + +describe('getToolDisplayTitle for the vfs verbs', () => { + it('shows the created file name', () => { + expect( + getToolDisplayTitle('create_file', { + outputs: { + files: [{ path: 'files/Reports/Quarterly%20Report.pdf', mode: 'create' }], + }, + }) + ).toBe('Creating Quarterly Report.pdf') + expect(getToolDisplayTitle('create_file', { fileName: 'notes.md' })).toBe('Creating notes.md') + expect(getToolDisplayTitle('create_file')).toBe('Creating file') + }) + + it('shows deleted file and folder names', () => { + expect( + getToolDisplayTitle('delete_file', { + paths: ['files/Reports/Old%20Report.pdf'], + }) + ).toBe('Deleting Old Report.pdf') + expect( + getToolDisplayTitle('delete_file_folder', { + paths: ['files/Old%20Reports', 'files/Drafts'], + }) + ).toBe('Deleting Old Reports and Drafts') + }) + + it('uses the derived verb for mv titles', () => { + expect( + getToolDisplayTitle('mv', { + sources: ['workflows/falling-vacuum'], + destination: 'workflows/failing-vacuum', + toolTitle: 'falling-vacuum to failing-vacuum', + }) + ).toBe('Renaming falling-vacuum to failing-vacuum') + expect( + getToolDisplayTitle('mv', { + sources: ['files/a.png', 'files/b.png'], + destination: 'files/Images/', + toolTitle: '2 files to Images', + }) + ).toBe('Moving 2 files to Images') + }) + + it('titles cp and mkdir by intent', () => { + expect(getToolDisplayTitle('cp', { toolTitle: 'My Workflow' })).toBe('Duplicating My Workflow') + expect(getToolDisplayTitle('mkdir', { toolTitle: 'Reports/2026' })).toBe( + 'Creating Reports/2026' + ) + expect(getToolDisplayTitle('cp', {})).toBe('Duplicating workflow') + expect(getToolDisplayTitle('mkdir', {})).toBe('Creating folder') + }) +}) + +describe('getToolDisplayTitle for workflow resources', () => { + it('shows workflow names for lifecycle actions', () => { + expect(getToolDisplayTitle('create_workflow', { name: 'Lead Router' })).toBe( + 'Creating Lead Router' + ) + expect(getToolDisplayTitle('edit_workflow', { workflowName: 'Lead Router' })).toBe( + 'Editing Lead Router' + ) + expect( + getToolDisplayTitle('delete_workflow', { + workflowNames: ['Lead Router', 'Lead Enricher'], + }) + ).toBe('Deleting Lead Router and Lead Enricher') + }) +}) + +describe('getToolDisplayTitle for managed resources', () => { + it.each([ + [ + 'manage_custom_tool', + { + operation: 'add', + schema: { function: { name: 'lookupWeather' } }, + }, + 'Creating lookupWeather', + ], + ['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'], + ['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'], + [ + 'manage_scheduled_task', + { operation: 'create', args: { title: 'Morning Digest' } }, + 'Creating Morning Digest', + ], + [ + 'manage_credential', + { + operation: 'rename', + previousDisplayName: 'Stripe', + displayName: 'Production Stripe', + }, + 'Renaming Stripe to Production Stripe', + ], + [ + 'manage_folder', + { operation: 'rename', path: 'workflows/Old%20Name', name: 'New Name' }, + 'Renaming Old Name to New Name', + ], + [ + 'manage_folder', + { operation: 'delete', path: 'workflows/Marketing/Q3%20Campaigns' }, + 'Deleting Q3 Campaigns', + ], + ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], + ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], + ['manage_skill', { operation: 'list' }, 'Viewing skills'], + ['manage_scheduled_task', { operation: 'get' }, 'Reading scheduled task'], + ['manage_scheduled_task', { operation: 'list' }, 'Viewing scheduled tasks'], + ])('uses verb + resource name for %s', (toolName, args, expected) => { + expect(getToolDisplayTitle(toolName, args)).toBe(expected) + }) +}) + +describe('getToolDisplayTitle for request-scoped MCP tools', () => { + it('hides the internal server id and humanizes the tool name', () => { + expect(getToolDisplayTitle('mcp-363de040-web_search_exa')).toBe('Web Search Exa') + }) +}) + +describe('getToolDisplayTitle for context management', () => { + it('describes compaction in user-facing language', () => { + expect(getToolDisplayTitle('context_compaction')).toBe('Summarizing context') + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 099503901d1..9ed7e1df6e9 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -16,6 +16,8 @@ import { stripVersionSuffix } from '@sim/utils/string' type ToolArgs = Record | undefined +export const CONTEXT_COMPACTION_DISPLAY_TITLE = 'Summarizing context' + function stringArg(args: ToolArgs, key: string): string { const value = args?.[key] return typeof value === 'string' ? value.trim() : '' @@ -41,13 +43,20 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): return firstStringArg(parent as Record, ...keys) } -function operationTitle( +interface OperationDisplay { + verb: string + resource: string +} + +function namedOperationTitle( args: ToolArgs, + target: string, placeholder: string, - labels: Record + labels: Record ): string { const operation = stringArg(args, 'operation') - return labels[operation] ?? placeholder + const display = labels[operation] + return display ? `${display.verb} ${target || display.resource}` : placeholder } function isWorkflowArtifactPath(path: string, filename: string): boolean { @@ -55,6 +64,91 @@ function isWorkflowArtifactPath(path: string, filename: string): boolean { return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) } +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +function pathLeaf(path: string): string { + const normalized = path.replace(/\/+$/, '') + const leaf = normalized.split('/').filter(Boolean).at(-1) || normalized + return decodePathSegment(leaf) +} + +function summarizeTargets(targets: string[], fallback: string): string { + const normalized = targets.map((target) => target.trim()).filter(Boolean) + if (normalized.length === 0) return fallback + if (normalized.length === 1) return normalized[0] + if (normalized.length === 2) return `${normalized[0]} and ${normalized[1]}` + return `${normalized[0]}, ${normalized[1]}, and ${normalized.length - 2} more` +} + +function countedResourceTarget( + args: ToolArgs, + key: string, + singular: string, + plural: string +): string { + const values = args?.[key] + return Array.isArray(values) && values.length > 1 ? `${values.length} ${plural}` : singular +} + +function firstOutputFilePath(args: ToolArgs): string { + const outputs = args?.outputs + if (!outputs || typeof outputs !== 'object') return '' + const files = (outputs as Record).files + if (!Array.isArray(files)) return '' + + for (const file of files) { + if (!file || typeof file !== 'object') continue + const path = stringArg(file as Record, 'path') + if (path) return path + } + return '' +} + +function createFileTitle(args: ToolArgs): string { + const nestedArgs = + args?.args && typeof args.args === 'object' ? (args.args as Record) : undefined + const target = + firstOutputFilePath(args) || + firstStringArg(args, 'fileName') || + firstOutputFilePath(nestedArgs) || + firstStringArg(nestedArgs, 'fileName') + if (!target) return 'Creating file' + return `Creating ${pathLeaf(target)}` +} + +/** + * Verb for an mv call, derived from its arguments so the row reads as what + * the call actually does: a single source whose parent path matches the + * destination's (only the leaf changes) is a rename; multiple sources, a + * trailing-slash folder destination, or a parent change is a move. Segments + * are decoded so an encoded source compares correctly against a plain-text + * destination leaf. + */ +export function mvDisplayVerb( + source: string | undefined, + destination: string | undefined +): 'Renaming' | 'Moving' { + if (!source || !destination || /\/\s*$/.test(destination)) return 'Moving' + const segments = (path: string) => + path + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .map(decodePathSegment) + const src = segments(source) + const dst = segments(destination) + if (src.length < 2 || dst.length < 2) return 'Moving' + const sameParent = src.slice(0, -1).join('/') === dst.slice(0, -1).join('/') + const leafChanged = src.at(-1) !== dst.at(-1) + return sameParent && leafChanged ? 'Renaming' : 'Moving' +} + function workspaceFileTitle(args: ToolArgs): string { const title = stringArg(args, 'title') if (!title) return '' @@ -72,15 +166,21 @@ function workspaceFileTitle(args: ToolArgs): string { /** Static fallback titles for tools without an argument-aware title. */ const TOOL_TITLES: Record = { + // Gateway rows brand from the streamed toolId as soon as it resolves; this + // covers only the instant before the integration is known. The raw + // humanized name ("Call Integration Tool") must never render. + call_integration_tool: 'Calling integration', read: 'Reading file', search_library_docs: 'Searching library docs', - user_memory: 'Accessing memory', user_table: 'Managing table', + run_code: 'Running code', + query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', knowledge_base: 'Managing knowledge base', + search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', @@ -148,8 +248,11 @@ const TOOL_TITLES: Record = { scheduled_task: 'Scheduled Task Agent', agent: 'Tools Agent', research: 'Research Agent', + scout: 'Scout Agent', + search: 'Search Agent', media: 'Media Agent', superagent: 'Executing action', + context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE, } /** Acronyms that must keep their canonical casing when humanized. */ @@ -180,7 +283,49 @@ export function humanizeToolName(name: string): string { * returns an empty string. */ export function getToolDisplayTitle(name: string, args?: Record): string { + const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) + if (mcpToolMatch?.[1]) { + return humanizeToolName(mcpToolMatch[1]) + } + switch (name) { + case 'create_file': + return createFileTitle(args) + case 'delete_file': { + const targets = stringArrayArg(args, 'paths').map(pathLeaf) + return `Deleting ${summarizeTargets(targets, 'file')}` + } + case 'delete_file_folder': { + const targets = stringArrayArg(args, 'paths').map(pathLeaf) + return `Deleting ${summarizeTargets(targets, 'folder')}` + } + case 'create_workflow': { + const target = firstStringArg(args, 'name', 'workflowName', 'title') + return `Creating ${target || 'workflow'}` + } + case 'edit_workflow': { + const target = firstStringArg(args, 'workflowName', 'name', 'title') + return `Editing ${target || 'workflow'}` + } + case 'delete_workflow': { + const target = summarizeTargets( + stringArrayArg(args, 'workflowNames'), + countedResourceTarget(args, 'workflowIds', 'workflow', 'workflows') + ) + return `Deleting ${target}` + } + case 'create_workspace_mcp_server': { + const target = firstStringArg(args, 'name', 'serverName', 'title') + return `Creating ${target || 'MCP server'}` + } + case 'update_workspace_mcp_server': { + const target = firstStringArg(args, 'name', 'serverName', 'title') + return `Updating ${target || 'MCP server'}` + } + case 'delete_workspace_mcp_server': { + const target = firstStringArg(args, 'serverName', 'name', 'title') + return `Deleting ${target || 'MCP server'}` + } case 'search_online': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' @@ -193,6 +338,25 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Finding ${target}` : 'Finding files' } + case 'mv': { + const sources = stringArrayArg(args, 'sources') + const verb = + sources.length === 1 ? mvDisplayVerb(sources[0], stringArg(args, 'destination')) : 'Moving' + if (verb === 'Renaming' && sources[0]) { + const destination = stringArg(args, 'destination') + if (destination) return `Renaming ${pathLeaf(sources[0])} to ${pathLeaf(destination)}` + } + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `${verb} ${target}` : verb + } + case 'cp': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Duplicating ${target}` : 'Duplicating workflow' + } + case 'mkdir': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Creating ${target}` : 'Creating folder' + } case 'enrichment_run': { const subject = nestedStringArg( args, @@ -219,47 +383,90 @@ export function getToolDisplayTitle(name: string, args?: Record if (urls.length > 1) return `Getting ${urls.length} pages` return 'Getting page contents' } - case 'manage_custom_tool': - return operationTitle(args, 'Custom tool action', { - add: 'Creating custom tool', - edit: 'Updating custom tool', - delete: 'Deleting custom tool', - list: 'Listing custom tools', + case 'manage_custom_tool': { + const schema = args?.schema + const target = + firstStringArg(args, 'toolTitle', 'title', 'name') || + (schema && typeof schema === 'object' + ? nestedStringArg(schema as Record, 'function', 'name') + : '') + return namedOperationTitle(args, target, 'Custom tool action', { + add: { verb: 'Creating', resource: 'custom tool' }, + edit: { verb: 'Updating', resource: 'custom tool' }, + delete: { verb: 'Deleting', resource: 'custom tool' }, + list: { verb: 'Viewing', resource: 'custom tools' }, }) - case 'manage_mcp_tool': - return operationTitle(args, 'MCP server action', { - add: 'Creating MCP server', - edit: 'Updating MCP server', - delete: 'Deleting MCP server', - list: 'Listing MCP servers', + } + case 'manage_mcp_tool': { + const target = + firstStringArg(args, 'serverName', 'name', 'title') || + nestedStringArg(args, 'config', 'name') + return namedOperationTitle(args, target, 'MCP server action', { + add: { verb: 'Creating', resource: 'MCP server' }, + edit: { verb: 'Updating', resource: 'MCP server' }, + delete: { verb: 'Deleting', resource: 'MCP server' }, + list: { verb: 'Viewing', resource: 'MCP servers' }, }) - case 'manage_skill': - return operationTitle(args, 'Skill action', { - add: 'Creating skill', - edit: 'Updating skill', - delete: 'Deleting skill', - list: 'Listing skills', + } + case 'manage_skill': { + const target = firstStringArg(args, 'name', 'skillName', 'title') + return namedOperationTitle(args, target, 'Skill action', { + add: { verb: 'Creating', resource: 'skill' }, + edit: { verb: 'Updating', resource: 'skill' }, + delete: { verb: 'Deleting', resource: 'skill' }, + list: { verb: 'Viewing', resource: 'skills' }, }) - case 'manage_scheduled_task': - return operationTitle(args, 'Scheduled task action', { - create: 'Creating scheduled task', - get: 'Getting scheduled task', - update: 'Updating scheduled task', - delete: 'Deleting scheduled task', - list: 'Listing scheduled tasks', + } + case 'manage_scheduled_task': { + const target = + firstStringArg(args, 'title', 'taskName', 'name') || nestedStringArg(args, 'args', 'title') + return namedOperationTitle(args, target, 'Scheduled task action', { + create: { verb: 'Creating', resource: 'scheduled task' }, + get: { verb: 'Reading', resource: 'scheduled task' }, + update: { verb: 'Updating', resource: 'scheduled task' }, + delete: { verb: 'Deleting', resource: 'scheduled task' }, + list: { verb: 'Viewing', resource: 'scheduled tasks' }, }) - case 'manage_credential': - return operationTitle(args, 'Credential action', { - rename: 'Renaming credential', - delete: 'Deleting credential', + } + case 'manage_credential': { + const operation = stringArg(args, 'operation') + if (operation === 'rename') { + const from = firstStringArg(args, 'previousDisplayName', 'oldName', 'credentialName') + const to = firstStringArg(args, 'displayName', 'newName', 'name', 'title') + if (from && to) return `Renaming ${from} to ${to}` + return to ? `Renaming credential to ${to}` : 'Renaming credential' + } + const target = firstStringArg(args, 'credentialName', 'displayName', 'name', 'title') + return namedOperationTitle(args, target, 'Credential action', { + delete: { verb: 'Deleting', resource: 'credential' }, }) - case 'manage_folder': - return operationTitle(args, 'Folder action', { - create: 'Creating folder', - rename: 'Renaming folder', - move: 'Moving folder', - delete: 'Deleting folder', + } + case 'manage_folder': { + const operation = stringArg(args, 'operation') + if (operation === 'rename') { + const rawFrom = firstStringArg(args, 'oldPath', 'source', 'path', 'folderName') + const rawTo = firstStringArg(args, 'newPath', 'destination', 'newName', 'name', 'title') + const from = rawFrom ? pathLeaf(rawFrom) : '' + const to = rawTo ? pathLeaf(rawTo) : '' + if (from && to) return `Renaming ${from} to ${to}` + return to ? `Renaming folder to ${to}` : 'Renaming folder' + } + const rawTarget = firstStringArg( + args, + 'newPath', + 'destination', + 'path', + 'folderName', + 'name', + 'title' + ) + const target = rawTarget ? pathLeaf(rawTarget) : '' + return namedOperationTitle(args, target, 'Folder action', { + create: { verb: 'Creating', resource: 'folder' }, + move: { verb: 'Moving', resource: 'folder' }, + delete: { verb: 'Deleting', resource: 'folder' }, }) + } case 'run_workflow': case 'run_from_block': case 'run_workflow_until_block': @@ -294,6 +501,7 @@ const COMPLETED_VERB_REWRITES: Record = { Accessing: 'Accessed', Adding: 'Added', Applying: 'Applied', + Calling: 'Called', Checking: 'Checked', Comparing: 'Compared', Completing: 'Completed', @@ -337,7 +545,9 @@ const COMPLETED_VERB_REWRITES: Record = { * completed tool call (e.g. "Querying logs for X" -> "Queried logs for X"). * Operates on the already-resolved title so enriched and persisted titles both * work. Returns undefined when the title has no leading gerund rewrite — the - * caller keeps the original. + * caller keeps the original. Integration gateway descriptions are base-form + * verb phrases ("Read recent emails") whose first word never matches a gerund + * key, so they intentionally pass through unchanged. */ export function getToolCompletedTitle(title: string): string | undefined { const spaceIndex = title.indexOf(' ') diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index f0181d0a396..0acdbbda432 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -281,6 +281,96 @@ describe('resource writer workflow aliases', () => { ) }) + it('auto-creates missing parent folders for plain workspace file creates', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-report', + name: 'summary.csv', + size: 7, + type: 'text/csv', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/csv', + }) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'summary.csv', + 'text/csv', + { folderId: 'folder-nested' } + ) + expect(result).toMatchObject({ + id: 'file-report', + vfsPath: 'files/Reports/2026/summary.csv', + mode: 'create', + }) + }) + + it('validates create targets read-only, resolving existing parent folders without creating', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') + mocks.getWorkspaceFileByName.mockResolvedValue(null) + + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + }) + + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(validation).toMatchObject({ + mode: 'create', + vfsPath: 'files/Reports/2026/summary.csv', + fileName: 'summary.csv', + folderId: 'folder-nested', + }) + }) + + it('accepts create targets with missing parent folders during validation without creating them', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + }) + + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.getWorkspaceFileByName).not.toHaveBeenCalled() + expect(validation).toMatchObject({ + mode: 'create', + vfsPath: 'files/Reports/2026/summary.csv', + fileName: 'summary.csv', + folderId: null, + }) + }) + it('reports alias path when exact-name alias backing creation conflicts', async () => { mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ kind: 'plan_file', diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 460f6ba36dd..148c0ee0333 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -57,6 +57,7 @@ export type WorkspaceFileWriteValidation = vfsPath: string backingVfsPath?: string fileName: string + /** Null for root targets AND for parent chains that don't exist yet — validation is read-only; missing folders are created at write time. */ folderId: string | null } | { @@ -97,22 +98,41 @@ export function parseWorkspaceFileCreatePath(path: string): { } } +/** + * Resolve a create-mode write target. Pass `createFolders` (the write path) to + * create missing parent folders; without it (the validation path) resolution + * is read-only — a missing parent chain yields `folderId: null`, since the + * folders are created at write time and nothing can conflict there yet. + */ async function resolveCreateTarget( workspaceId: string, - path: string + path: string, + createFolders?: { userId: string } ): Promise { const parsed = parseWorkspaceFileCreatePath(path) - const folderId = - parsed.folderSegments.length > 0 - ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) - : null - - if (parsed.folderSegments.length > 0 && !folderId) { - throw new Error( - `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` - ) + let folderId: string | null = null + if (parsed.folderSegments.length > 0) { + if (createFolders) { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: createFolders.userId, + pathSegments: parsed.folderSegments, + }) + if (!folderId) { + throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) + } + } else { + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, + } + } + } } const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) @@ -388,7 +408,9 @@ export async function writeWorkspaceFileByPath(args: { } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { + userId: args.userId, + }) const uploaded = await uploadWorkspaceFile( args.workspaceId, args.userId, diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts new file mode 100644 index 00000000000..8b6de1abddc --- /dev/null +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { serializeFileMeta, serializeKBMeta, serializeTableMeta } from './serializers' + +describe('VFS metadata serializers', () => { + it('includes the authoritative file update timestamp', () => { + const metadata = JSON.parse( + serializeFileMeta({ + id: 'file-1', + name: 'notes.md', + contentType: 'text/markdown', + size: 42, + uploadedAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T12:34:56.000Z'), + }) + ) + + expect(metadata.updatedAt).toBe('2026-07-09T12:34:56.000Z') + }) + + it('preserves live table and knowledge-base counts', () => { + const table = JSON.parse( + serializeTableMeta({ + id: 'table-1', + name: 'Customers', + schema: { columns: [] }, + rowCount: 137, + maxRows: 10_000, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T00:00:00.000Z'), + }) + ) + const knowledgeBase = JSON.parse( + serializeKBMeta({ + id: 'kb-1', + name: 'Handbook', + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + tokenCount: 12_345, + documentCount: 19, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T00:00:00.000Z'), + }) + ) + + expect(table.rowCount).toBe(137) + expect(knowledgeBase.documentCount).toBe(19) + }) +}) + +describe('serializeKBMeta', () => { + const baseKb = { + id: 'kb-1', + name: 'Support Docs', + description: null, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + tokenCount: 42, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + documentCount: 3, + } + + it('includes tag definitions when present', () => { + const json = JSON.parse( + serializeKBMeta({ + ...baseKb, + tagDefinitions: [ + { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text' }, + { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text' }, + ], + }) + ) + + const textOperators = ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'ends_with'] + expect(json.tagDefinitions).toEqual([ + { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text', operators: textOperators }, + { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text', operators: textOperators }, + ]) + }) + + // `between` is legal for number/date but not text/boolean -- the agent cannot infer this. + it.each([ + ['number', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], + ['date', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], + ['boolean', ['eq', 'neq']], + ])('exposes the operators legal for a %s tag', (fieldType, expected) => { + const json = JSON.parse( + serializeKBMeta({ + ...baseKb, + tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType }], + }) + ) + + expect(json.tagDefinitions[0].operators).toEqual(expected) + }) + + it('emits an empty operator list for an unrecognized field type rather than throwing', () => { + const json = JSON.parse( + serializeKBMeta({ + ...baseKb, + tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType: 'mystery' }], + }) + ) + + expect(json.tagDefinitions[0].operators).toEqual([]) + }) + + it('omits tag definitions when empty or undefined', () => { + const empty = JSON.parse(serializeKBMeta({ ...baseKb, tagDefinitions: [] })) + const missing = JSON.parse(serializeKBMeta(baseKb)) + + expect(empty).not.toHaveProperty('tagDefinitions') + expect(missing).not.toHaveProperty('tagDefinitions') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 571768bad6a..ae7a971a794 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,6 +1,6 @@ -import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' +import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' @@ -85,7 +85,26 @@ export function serializeRecentExecutions( } /** - * Serialize knowledge base metadata for VFS meta.json + * A knowledge base tag definition, reduced to the fields the agent needs to bind a tag filter. + * + * @remarks + * `tagName` is the DB's `displayName`. It is renamed at this boundary because that is the key + * a `tagFilters` entry must carry -- an entry written with `displayName` validates and persists + * but never filters anything. + */ +export interface KbTagDefinitionSummary { + tagName: string + tagSlot: string + fieldType: string +} + +/** + * Serialize knowledge base metadata for VFS meta.json. + * + * `tagDefinitions` exposes the KB's defined tags (`tagName` → `tagSlot`) plus the operators + * legal for each tag's `fieldType`, so the agent can bind a knowledge-tag filter without + * guessing a tag name it cannot otherwise see or an operator the field does not accept + * (`between` is valid for number/date but not text/boolean). */ export function serializeKBMeta(kb: { id: string @@ -98,6 +117,7 @@ export function serializeKBMeta(kb: { updatedAt: Date documentCount: number connectorTypes?: string[] + tagDefinitions?: KbTagDefinitionSummary[] }): string { return JSON.stringify( { @@ -110,6 +130,15 @@ export function serializeKBMeta(kb: { documentCount: kb.documentCount, connectorTypes: kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined, + tagDefinitions: + kb.tagDefinitions && kb.tagDefinitions.length > 0 + ? kb.tagDefinitions.map((tag) => ({ + ...tag, + operators: getOperatorsForFieldType(tag.fieldType as FilterFieldType).map( + (op) => op.value + ), + })) + : undefined, createdAt: kb.createdAt.toISOString(), updatedAt: kb.updatedAt.toISOString(), }, @@ -291,6 +320,7 @@ export function serializeFileMeta(file: { contentType: string size: number uploadedAt: Date + updatedAt: Date }): string { return JSON.stringify( { @@ -302,6 +332,7 @@ export function serializeFileMeta(file: { contentType: file.contentType, size: file.size, uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, @@ -669,7 +700,7 @@ export function serializeCustomTool(tool: { id: tool.id, title: tool.title, schema: tool.schema, - codePreview: truncate(tool.code, 500), + code: tool.code, }, null, 2 @@ -716,7 +747,7 @@ export function serializeSkill(s: { id: s.id, name: s.name, description: s.description, - contentPreview: truncate(s.content, 500), + content: s.content, createdAt: s.createdAt.toISOString(), }, null, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 8305ccaf121..d83c7fecb17 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -3,10 +3,13 @@ import { db } from '@sim/db' import { chat as chatTable, copilotChats, + customTools as customToolsTable, document, jobExecutionLogs, + knowledgeBaseTagDefinitions, knowledgeConnector, mcpServers as mcpServersTable, + skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, workflowFolder, @@ -16,7 +19,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { buildWorkspaceContextMd, @@ -48,7 +51,7 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import type { DeploymentData } from '@/lib/copilot/vfs/serializers' +import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers' import { serializeApiKeys, serializeBlockSchema, @@ -109,13 +112,13 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowDeploymentSnapshot, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { listSkills } from '@/lib/workflows/skills/operations' +import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess, @@ -1540,6 +1543,8 @@ export class WorkspaceVFS { ): Promise { const kbs = await getKnowledgeBases(userId, workspaceId) + const tagDefinitionsByKb = await this.loadKbTagDefinitions(kbs.map((kb) => kb.id)) + await Promise.all( kbs.map(async (kb) => { const safeName = sanitizeName(kb.name) @@ -1558,6 +1563,7 @@ export class WorkspaceVFS { updatedAt: kb.updatedAt, documentCount: kb.docCount, connectorTypes: kb.connectorTypes, + tagDefinitions: tagDefinitionsByKb.get(kb.id), }) ) @@ -1629,6 +1635,61 @@ export class WorkspaceVFS { })) } + /** + * Load tag definitions for the given knowledge bases in a single query, grouped by + * KB id and ordered by tag slot. Surfaced inline in each KB's meta.json so the agent + * knows which tags exist (and their slot binding) when editing a knowledge-tag filter. + * + * @remarks + * Tag definitions are an optional enrichment, so a query failure degrades to a meta.json + * without them rather than rejecting. This materializer runs inside the top-level + * `Promise.all`, whose rejection would fail the entire workspace VFS build and leave the + * agent unable to read any file. + */ + private async loadKbTagDefinitions( + kbIds: string[] + ): Promise> { + const byKb = new Map() + if (kbIds.length === 0) return byKb + + let rows: Array<{ + knowledgeBaseId: string + tagSlot: string + displayName: string + fieldType: string + }> + try { + rows = await db + .select({ + knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + fieldType: knowledgeBaseTagDefinitions.fieldType, + }) + .from(knowledgeBaseTagDefinitions) + .where(inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, kbIds)) + .orderBy(knowledgeBaseTagDefinitions.tagSlot) + } catch (err) { + logger.warn('Failed to load knowledge base tag definitions', { + error: toError(err).message, + }) + return byKb + } + + for (const row of rows) { + const entry = { + tagName: row.displayName, + tagSlot: row.tagSlot, + fieldType: row.fieldType, + } + const existing = byKb.get(row.knowledgeBaseId) + if (existing) existing.push(entry) + else byKb.set(row.knowledgeBaseId, [entry]) + } + + return byKb + } + /** * Materialize tables using the shared listTables function. * Returns a summary for WORKSPACE.md generation. @@ -1661,11 +1722,11 @@ export class WorkspaceVFS { rowCount: t.rowCount, })) } catch (err) { - logger.warn('Failed to materialize tables', { + logger.error('Failed to materialize tables; refusing to serve an incomplete VFS', { workspaceId, error: toError(err).message, }) - return [] + throw err } } @@ -1682,6 +1743,7 @@ export class WorkspaceVFS { const files = await listWorkspaceFiles(workspaceId, { folders, includeReservedSystemFiles: true, + throwOnError: true, }) for (const folder of folders) { if ( @@ -1712,6 +1774,7 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, + updatedAt: file.updatedAt, }) ) } @@ -1797,11 +1860,11 @@ export class WorkspaceVFS { folderPath: f.folderPath ?? null, })) } catch (err) { - logger.warn('Failed to materialize files', { + logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { workspaceId, error: toError(err).message, }) - return [] + throw err } } @@ -1859,6 +1922,11 @@ export class WorkspaceVFS { eq(workflowDeploymentVersion.isActive, true) ) ) + // Match checkNeedsRedeployment/loadDeployedWorkflowState. Historical + // workflows can contain more than one active row, so an unordered + // limit may compare the draft with an older deployment while the UI + // correctly compares against the newest active deployment. + .orderBy(desc(workflowDeploymentVersion.createdAt)) .limit(1) : Promise.resolve([]), db @@ -1914,25 +1982,47 @@ export class WorkspaceVFS { } /** - * Materialize custom tools using the shared listCustomTools function. + * Advertise custom tools in the VFS without eagerly loading their code. + * Paths are registered as lazy so glob/WORKSPACE.md see them, but full + * schema+code is fetched only when read (or a grep whose scope touches them). */ private async materializeCustomTools( workspaceId: string, userId: string ): Promise> { try { - const toolRows = await listCustomTools({ userId, workspaceId }) + // Metadata only — tool code can be large; keep it out of the eager map. + // Visibility matches listCustomTools: workspace tools + legacy user-owned. + const toolRows = await db + .select({ + id: customToolsTable.id, + title: customToolsTable.title, + }) + .from(customToolsTable) + .where( + or( + eq(customToolsTable.workspaceId, workspaceId), + and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) + ) + ) + .orderBy(desc(customToolsTable.createdAt)) for (const tool of toolRows) { const safeName = sanitizeName(tool.title) - const serialized = serializeCustomTool({ - id: tool.id, - title: tool.title, - schema: tool.schema, - code: tool.code, - }) - this.files.set(`custom-tools/${safeName}.json`, serialized) - this.files.set(`agent/custom-tools/${safeName}.json`, serialized) + const toolId = tool.id + const load = async () => { + const full = await getCustomToolById({ toolId, userId, workspaceId }) + if (!full) return null + return serializeCustomTool({ + id: full.id, + title: full.title, + schema: full.schema, + code: full.code, + }) + } + // Legacy alias + canonical agent/ path — each resolves independently on read. + this.registerLazy(`custom-tools/${safeName}.json`, load) + this.registerLazy(`agent/custom-tools/${safeName}.json`, load) } return toolRows.map((t) => ({ id: t.id, name: t.title })) @@ -2031,26 +2121,39 @@ export class WorkspaceVFS { } /** - * Materialize workspace skills using the shared listSkills function. + * Advertise workspace skills in the VFS without eagerly loading their bodies. + * Paths are registered as lazy so glob/WORKSPACE.md see them, but full content + * is fetched only when read (or a grep whose scope touches the path) resolves them. */ private async materializeSkills( workspaceId: string ): Promise> { try { - const skillRows = await listSkills({ workspaceId, includeBuiltins: false }) + // Metadata only — skill bodies can be large; keep them out of the eager map. + const skillRows = await db + .select({ + id: skillTable.id, + name: skillTable.name, + description: skillTable.description, + }) + .from(skillTable) + .where(eq(skillTable.workspaceId, workspaceId)) + .orderBy(desc(skillTable.createdAt)) for (const s of skillRows) { const safeName = sanitizeName(s.name) - this.files.set( - `agent/skills/${safeName}.json`, - serializeSkill({ - id: s.id, - name: s.name, - description: s.description, - content: s.content, - createdAt: s.createdAt, + const skillId = s.id + this.registerLazy(`agent/skills/${safeName}.json`, async () => { + const full = await getSkillById({ skillId, workspaceId }) + if (!full) return null + return serializeSkill({ + id: full.id, + name: full.name, + description: full.description, + content: full.content, + createdAt: full.createdAt, }) - ) + }) } return skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })) @@ -2365,6 +2468,7 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, + updatedAt: file.updatedAt, }) ) } diff --git a/apps/sim/lib/core/async-jobs/backends/database.test.ts b/apps/sim/lib/core/async-jobs/backends/database.test.ts index c30ee727ffc..1d7207031bc 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => ({ @@ -78,3 +79,36 @@ describe('DatabaseJobQueue enqueue', () => { }) }) }) + +describe('DatabaseJobQueue batchEnqueueAndWait', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('caps overlapping batches sharing a concurrencyKey at the shared limit', async () => { + const queue = new DatabaseJobQueue() + let inFlight = 0 + let maxInFlight = 0 + const makeItem = () => ({ + payload: {}, + options: { + concurrencyKey: 'table-1', + concurrencyLimit: 2, + runner: async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await sleep(1) + inFlight -= 1 + }, + }, + }) + + await Promise.all([ + queue.batchEnqueueAndWait('workflow-group-cell', [makeItem(), makeItem()]), + queue.batchEnqueueAndWait('workflow-group-cell', [makeItem(), makeItem()]), + ]) + + expect(maxInFlight).toBe(2) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 8685a4fe7e8..45feb6ed412 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -207,7 +207,23 @@ export class DatabaseJobQueue implements JobQueueBackend { inlineCancelKeyControllers.set(cancelKey, controller) tracked.push({ key: cancelKey, controller }) } - return runner(item.payload, controller.signal).catch((err) => { + // Same shared-key semaphore as `runInline`: without it, overlapping + // batches on one concurrencyKey (e.g. two dispatches on one table) would + // each run their full window concurrently instead of sharing the cap. + const { concurrencyKey, concurrencyLimit } = item.options ?? {} + const run = async () => { + if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { + await acquireSlot(concurrencyKey, concurrencyLimit) + try { + await runner(item.payload, controller.signal) + } finally { + releaseSlot(concurrencyKey) + } + return + } + await runner(item.payload, controller.signal) + } + return run().catch((err) => { logger.error(`[${type}] Inline run failed`, { cancelKey, error: toError(err).message, diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index b435da46f22..c8812aa05e4 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -13,7 +13,8 @@ export function getRotatingApiKey(provider: string): string { provider !== 'gemini' && provider !== 'cohere' && provider !== 'zai' && - provider !== 'xai' + provider !== 'xai' && + provider !== 'kimi' ) { throw new Error(`No rotation implemented for provider: ${provider}`) } @@ -44,6 +45,10 @@ export function getRotatingApiKey(provider: string): string { if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1) if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2) if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3) + } else if (provider === 'kimi') { + if (env.KIMI_API_KEY_1) keys.push(env.KIMI_API_KEY_1) + if (env.KIMI_API_KEY_2) keys.push(env.KIMI_API_KEY_2) + if (env.KIMI_API_KEY_3) keys.push(env.KIMI_API_KEY_3) } if (keys.length === 0) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index cbb95efd5fb..386ddbb5506 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -103,6 +103,8 @@ export const env = createEnv({ ENTERPRISE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on enterprise tier (default: 1000000) TABLE_MAX_ROW_SIZE_BYTES: z.number().optional(), // Max serialized size in bytes of a single user-table row (default: 409600) TABLE_MAX_PAGE_BYTES: z.number().optional(), // Dev-preview: byte budget per row-page read; pages cut early past it (unset = disabled) + TABLE_DISPATCH_CONCURRENCY_FREE: z.number().optional(), // Rows one table run executes in parallel on free tier (default: 20) + TABLE_DISPATCH_CONCURRENCY_PAID: z.number().optional(), // Rows one table run executes in parallel on paid tiers (default: 50) // Credit-tier Stripe prices (monthly) STRIPE_PRICE_TIER_25_MO: z.string().min(1).optional(), // Pro: $25/mo (6,000 credits) @@ -154,6 +156,9 @@ export const env = createEnv({ ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing + KIMI_API_KEY_1: z.string().min(1).optional(), // Primary Kimi (Moonshot AI) API key for load balancing + KIMI_API_KEY_2: z.string().min(1).optional(), // Additional Kimi API key for load balancing + KIMI_API_KEY_3: z.string().min(1).optional(), // Additional Kimi API key for load balancing XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing @@ -273,6 +278,18 @@ export const env = createEnv({ AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: z.string().optional(), // Azure container for OpenGraph images AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME: z.string().optional(), // Azure container for workspace logos + // Cloud Storage - Google Cloud Storage + GCS_PROJECT_ID: z.string().optional(), // GCP project ID (optional — inferred from credentials/ADC when unset) + GCS_CREDENTIALS_JSON: z.string().optional(), // Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS) + GCS_BUCKET_NAME: z.string().optional(), // GCS bucket for general file storage (enables GCS) + GCS_KB_BUCKET_NAME: z.string().optional(), // GCS bucket for knowledge base files + GCS_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // GCS bucket for workflow execution files + GCS_CHAT_BUCKET_NAME: z.string().optional(), // GCS bucket for chat logos + GCS_COPILOT_BUCKET_NAME: z.string().optional(), // GCS bucket for copilot files + GCS_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // GCS bucket for profile pictures + GCS_OG_IMAGES_BUCKET_NAME: z.string().optional(), // GCS bucket for OpenGraph images + GCS_WORKSPACE_LOGOS_BUCKET_NAME: z.string().optional(), // GCS bucket for workspace logos + // Admission & Burst Protection ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod @@ -391,6 +408,8 @@ export const env = createEnv({ PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret + CLICKUP_CLIENT_ID: z.string().optional(), // ClickUp OAuth client ID + CLICKUP_CLIENT_SECRET: z.string().optional(), // ClickUp OAuth client secret BOX_CLIENT_ID: z.string().optional(), // Box OAuth client ID BOX_CLIENT_SECRET: z.string().optional(), // Box OAuth client secret DROPBOX_CLIENT_ID: z.string().optional(), // Dropbox OAuth client ID diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts index 192f6e9deb5..3fd89d21f61 100644 --- a/apps/sim/lib/core/outbox/service.test.ts +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -395,6 +395,30 @@ describe('processOutboxEvents — handler timeout', () => { expect(timeoutUpdate?.set.attempts).toBe(1) expect(timeoutUpdate?.set.lastError).toMatch(/timed out/) }) + + it('aborts the handler signal when its execution window expires', async () => { + let handlerSignal: AbortSignal | undefined + const handler = vi.fn( + async ( + _payload: unknown, + context: { maxAttempts: number; signal: AbortSignal } + ): Promise => { + handlerSignal = context.signal + expect(context.maxAttempts).toBe(10) + await new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + } + ) + state.claimedRows = [makePendingRow({ attempts: 0 })] + + const promise = processOutboxEvents({ 'test.event': handler }) + await vi.advanceTimersByTimeAsync(90 * 1000 + 1) + const result = await promise + + expect(handlerSignal?.aborted).toBe(true) + expect(result.leaseLost).toBe(1) + }) }) describe('processOutboxEvents — reaper recovery', () => { diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts index 334ef2cbaff..d519a4b4c78 100644 --- a/apps/sim/lib/core/outbox/service.ts +++ b/apps/sim/lib/core/outbox/service.ts @@ -3,11 +3,23 @@ import { outboxEvent } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' import { and, asc, eq, inArray, lte, sql } from 'drizzle-orm' const logger = createLogger('OutboxService') const DEFAULT_MAX_ATTEMPTS = 10 +const MAX_PERSISTED_ERROR_LENGTH = 500 + +/** + * Bounds a handler failure before persisting it to `last_error`. Driver + * errors ("Failed query: ...\nparams: ...") embed every bound parameter, + * which can include user credentials from handler payloads — the parameter + * tail is dropped and the rest is capped. + */ +function toPersistedHandlerError(error: unknown): string { + return truncate(toError(error).message.split(/\nparams: /)[0], MAX_PERSISTED_ERROR_LENGTH) +} const STUCK_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes const MAX_BACKOFF_MS = 60 * 60 * 1000 // 1 hour const BASE_BACKOFF_MS = 1000 // 1 second, doubled per attempt @@ -37,6 +49,13 @@ export interface OutboxEventContext { eventType: string /** How many times this event has been attempted (zero on first run). */ attempts: number + /** Maximum attempts before this event is dead-lettered. */ + maxAttempts: number + /** + * Aborted when the handler exceeds its lease-bound execution window. + * External-operation handlers must stop before performing another side effect. + */ + signal: AbortSignal /** * Durably shallow-merge fields into this event's JSON payload while the * current processing lease is still held. Long-running handlers can @@ -397,7 +416,7 @@ async function runHandler( const nextAttempts = event.attempts + 1 const isDead = nextAttempts >= event.maxAttempts - const errMsg = toError(error).message + const errMsg = toPersistedHandlerError(error) if (isDead) { const updated = await updateIfLeaseHeld(event, { @@ -558,13 +577,18 @@ function runHandlerWithTimeout( event: typeof outboxEvent.$inferSelect, timeoutMs: number = DEFAULT_HANDLER_TIMEOUT_MS ): Promise { + const controller = new AbortController() const context: OutboxEventContext = { eventId: event.id, eventType: event.eventType, attempts: event.attempts, + maxAttempts: event.maxAttempts, + signal: controller.signal, checkpointPayload: async (patch) => { + controller.signal.throwIfAborted() const updated = await mergePayloadIfLeaseHeld(event, patch) if (!updated) { + controller.abort() throw new Error(`Outbox lease lost while checkpointing event ${event.id}`) } event.payload = { @@ -576,6 +600,7 @@ function runHandlerWithTimeout( return new Promise((resolve, reject) => { const timeout = setTimeout(() => { + controller.abort() reject(new OutboxHandlerTimeoutError(timeoutMs)) }, timeoutMs) diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts new file mode 100644 index 00000000000..fab342a0c38 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -0,0 +1,211 @@ +/** + * Client-safe descriptors for client-credentials service-account providers. + * + * A client-credential account is a `service_account`-type credential where a + * workspace admin pastes an OAuth client id + client secret + provider org + * identifier instead of a long-lived token. Unlike the token-paste family + * (whose stored secret IS the access token), these credentials mint a + * short-lived access token on demand via the provider's client-credentials + * grant (Zoom Server-to-Server OAuth, Box CCG). This module holds only + * UI/contract metadata (field lists, labels, docs links); the server-side + * minting registry lives in `@/lib/credentials/client-credential-accounts/server`. + */ + +/** Discriminator stored inside every encrypted client-credential secret blob. */ +export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const + +/** Contract field ids a client-credential connect modal collects. */ +export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId' + +export interface ClientCredentialAccountField { + id: ClientCredentialAccountFieldId + label: string + placeholder: string + /** Rendered with SecretInput and never echoed back. */ + secret: boolean + /** Soft-format hint shown while the current value doesn't match `hintPattern`. */ + hintPattern?: RegExp + hintMessage?: string + /** + * Normalizes the raw value before testing `hintPattern`, mirroring the + * server-side normalization so values the server accepts (e.g. a pasted + * `https://` URL) don't show a false format hint. + */ + hintNormalize?: (value: string) => string +} + +export interface ClientCredentialAccountDescriptor { + /** Stable credential `providerId` (`-service-account`). */ + providerId: string + /** Human service label used in modal copy and error messages (e.g. "Zoom"). */ + serviceLabel: string + /** + * Short vendor-accurate noun for connect-surface labels ("Add {connectNoun}"). + * Uses the vendor's own vocabulary for the credential. + */ + connectNoun: string + fields: ClientCredentialAccountField[] + /** Sim setup guide, docked bottom-left of the connect modal. */ + docsUrl: string + /** Optional one-line caveat rendered in the connect modal. */ + helpText?: string +} + +export const ZOOM_SERVICE_ACCOUNT_PROVIDER_ID = 'zoom-service-account' as const +export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const +export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const + +export type ClientCredentialAccountProviderId = + | typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID + | typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID + +/** + * Allowed My Domain host shapes: one org label (optionally with a + * `--sandboxName` suffix), an optional partition label (sandbox, develop, + * scratch, demo, patch, trailblaze, free), then `my.salesforce.com`. Covers + * production (`org.my.salesforce.com`), sandboxes + * (`org--sbx.sandbox.my.salesforce.com`), and Developer Edition + * (`org-dev-ed.develop.my.salesforce.com`). Gov/mil TLDs are excluded. + */ +export const SALESFORCE_MY_DOMAIN_HOST_REGEX = + /^[a-z0-9][a-z0-9-]*(--[a-z0-9]+)?(\.(sandbox|develop|scratch|demo|patch|trailblaze|free))?\.my\.salesforce\.com$/ + +/** + * Normalizes a pasted My Domain value to a bare host: strips the protocol, + * any path/query/fragment, and trailing content, then lowercases. Shared by + * the connect modal's format hint and the server-side minter so both judge + * the same normalized value. + */ +export function normalizeSalesforceMyDomainHost(rawHost: string): string { + return rawHost + .trim() + .replace(/^https?:\/\//i, '') + .replace(/[/?#].*$/, '') + .toLowerCase() +} + +export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< + ClientCredentialAccountProviderId, + ClientCredentialAccountDescriptor +> = { + [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Zoom', + connectNoun: 'server-to-server app', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Client ID from the App Credentials page', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Account ID from the App Credentials page', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + helpText: + "Copy all three values from the Server-to-Server OAuth app's App Credentials page — the Account ID there is not the account number shown in the Zoom web portal. The app must be activated before tokens can be issued.", + }, + [BOX_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: BOX_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Box', + connectNoun: 'service account', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Client ID from Configuration > OAuth 2.0 Credentials', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + { + id: 'orgId', + label: 'Enterprise ID', + placeholder: '1234567', + secret: false, + hintPattern: /^\d+$/, + hintMessage: 'Box Enterprise IDs are numeric.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/box-service-account', + helpText: + 'A Box admin must authorize the app in the Admin Console first, and the Service Account only sees folders it has been invited to as a collaborator.', + }, + [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Salesforce', + connectNoun: 'integration user app', + fields: [ + { + id: 'clientId', + label: 'Consumer key', + placeholder: "Consumer Key from the Connected App's Manage Consumer Details page", + secret: false, + }, + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + secret: true, + }, + { + id: 'orgId', + label: 'My Domain host', + placeholder: 'yourorg.my.salesforce.com', + secret: false, + hintPattern: SALESFORCE_MY_DOMAIN_HOST_REGEX, + hintNormalize: normalizeSalesforceMyDomainHost, + hintMessage: + 'Expected a My Domain host like yourorg.my.salesforce.com, yourorg--sbx.sandbox.my.salesforce.com, or yourorg-dev-ed.develop.my.salesforce.com.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + helpText: + 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.', + }, +} + +/** + * Required contract fields per client-credential provider, consumed by the + * `createCredentialBodySchema` superRefine so validation errors name the exact + * missing field. Derived from each descriptor's field list. + */ +export const CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: Record< + string, + ClientCredentialAccountFieldId[] +> = Object.fromEntries( + Object.values(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS).map((descriptor) => [ + descriptor.providerId, + descriptor.fields.map((field) => field.id), + ]) +) + +export function isClientCredentialAccountProviderId( + value: string | null | undefined +): value is ClientCredentialAccountProviderId { + return Boolean(value && Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, value)) +} + +export function getClientCredentialAccountDescriptor( + providerId: string | null | undefined +): ClientCredentialAccountDescriptor | undefined { + return isClientCredentialAccountProviderId(providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[providerId] + : undefined +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts new file mode 100644 index 00000000000..f380e8d8742 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts @@ -0,0 +1,245 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' + +const TOKEN_URL = 'https://api.box.com/oauth2/token' +const CURRENT_USER_URL = 'https://api.box.com/2.0/users/me' + +const FIELDS = { clientId: 'box-cid', clientSecret: 'box-secret', orgId: '1234567' } + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +function htmlResponse(status: number, body: string): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON') + }, + text: async () => body, + } as unknown as Response +} + +const mockFetch = vi.fn() + +function expectMintCall(): void { + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(TOKEN_URL) + expect(init.method).toBe('POST') + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const body = new URLSearchParams(init.body as string) + expect(body.get('grant_type')).toBe('client_credentials') + expect(body.get('client_id')).toBe('box-cid') + expect(body.get('client_secret')).toBe('box-secret') + expect(body.get('box_subject_type')).toBe('enterprise') + expect(body.get('box_subject_id')).toBe('1234567') +} + +function expectIdentityCall(): void { + const [url, init] = mockFetch.mock.calls[1] + expect(url).toBe(CURRENT_USER_URL) + expect(init.headers.Authorization).toBe('Bearer box-access') +} + +describe('mintBoxServiceAccountToken', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('mints a token and resolves the Service Account identity via users/me', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) + .mockResolvedValueOnce( + jsonResponse(200, { + name: 'Sim Automation', + login: 'AutomationUser_123_abc@boxdevedition.com', + }) + ) + + const result = await mintBoxServiceAccountToken(FIELDS) + + expect(result).toEqual({ + accessToken: 'box-access', + expiresInSeconds: 3600, + identity: { + displayName: 'Sim Automation', + auditMetadata: { + boxEnterpriseId: '1234567', + boxServiceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', + }, + storedMetadata: { + enterpriseId: '1234567', + serviceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', + }, + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + expectMintCall() + expectIdentityCall() + }) + + it('still succeeds with a fallback identity when users/me fails', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 2400 })) + .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' })) + + const result = await mintBoxServiceAccountToken(FIELDS) + + expect(result.accessToken).toBe('box-access') + expect(result.expiresInSeconds).toBe(2400) + expect(result.identity).toEqual({ + displayName: 'Box enterprise 1234567', + auditMetadata: { boxEnterpriseId: '1234567' }, + }) + }) + + it('still succeeds when the identity request itself throws', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) + .mockRejectedValueOnce(new TypeError('fetch failed')) + + const result = await mintBoxServiceAccountToken(FIELDS) + + expect(result.accessToken).toBe('box-access') + expect(result.identity?.displayName).toBe('Box enterprise 1234567') + }) + + it('throws invalid_credentials on 400 invalid_client', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'invalid_client', + error_description: 'The client credentials are not valid', + }) + ) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ hint: 'client credentials are not valid' }), + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('throws invalid_credentials with an authorization hint on 400 unauthorized_client', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'unauthorized_client', + error_description: 'This app is not authorized by the enterprise admin', + }) + ) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ + hint: 'app is not authorized by the enterprise admin (Platform Apps Manager)', + }), + }) + }) + + it('flags a wrong app type on the grant-type variant of unauthorized_client', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'unauthorized_client', + error_description: 'The grant type is unauthorized for this client_id', + }) + ) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: expect.objectContaining({ + hint: 'app was created as user authentication (OAuth 2.0) instead of Server Authentication', + }), + }) + }) + + it('throws invalid_credentials on 400 invalid_grant', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'invalid_grant', + error_description: 'Grant credentials are invalid', + }) + ) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ + hint: 'Client ID, Client Secret, and Enterprise ID do not all belong to the same app/enterprise, or the app has not been authorized in the Admin Console', + }), + }) + }) + + it('skips the identity lookup when skipIdentity is set', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'box-access', expires_in: 3600 }) + ) + + const result = await mintBoxServiceAccountToken(FIELDS, { skipIdentity: true }) + + expect(result).toEqual({ accessToken: 'box-access', expiresInSeconds: 3600 }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' })) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(503, 'unavailable')) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 503, + }) + }) + + it('throws provider_unavailable on a 200 with a non-JSON body', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page')) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when the success body is missing access_token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { token_type: 'bearer' })) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable on a network error', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + + await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts new file mode 100644 index 00000000000..720631d072d --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts @@ -0,0 +1,162 @@ +import type { + ClientCredentialAccountFields, + ClientCredentialAccountIdentity, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + fetchProvider, + isTransientProviderStatus, + parseProviderJson, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token' +const BOX_CURRENT_USER_URL = 'https://api.box.com/2.0/users/me' + +interface BoxTokenResponse { + access_token?: string + expires_in?: number +} + +interface BoxCurrentUserResponse { + name?: string + login?: string +} + +/** + * Maps a parsed Box OAuth2Error body to an operator-facing hint for server + * logs. Box reports every CCG auth failure as HTTP 400 with an `error` field, + * so the field value is the only way to distinguish bad credentials from a + * not-yet-authorized or wrongly-typed app. + */ +function boxErrorHint(body: string): string | undefined { + try { + const parsed = JSON.parse(body) as { error?: string; error_description?: string } + switch (parsed.error) { + case 'invalid_client': + return 'client credentials are not valid' + case 'unauthorized_client': + return /grant type/i.test(parsed.error_description ?? '') + ? 'app was created as user authentication (OAuth 2.0) instead of Server Authentication' + : 'app is not authorized by the enterprise admin (Platform Apps Manager)' + case 'invalid_grant': + return 'Client ID, Client Secret, and Enterprise ID do not all belong to the same app/enterprise, or the app has not been authorized in the Admin Console' + default: + return undefined + } + } catch { + return undefined + } +} + +/** + * Best-effort identity lookup for the app's Service Account user. A failure + * never fails the mint — the caller falls back to an Enterprise-ID-derived + * display name. + */ +async function fetchBoxServiceAccountIdentity( + accessToken: string, + orgId: string +): Promise { + const fallback: ClientCredentialAccountIdentity = { + displayName: `Box enterprise ${orgId}`, + auditMetadata: { boxEnterpriseId: orgId }, + } + try { + const res = await fetchProvider( + BOX_CURRENT_USER_URL, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + 'box_identity' + ) + if (!res.ok) return fallback + const user = await parseProviderJson(res, 'box_identity') + const login = typeof user.login === 'string' && user.login ? user.login : undefined + const name = typeof user.name === 'string' && user.name ? user.name : undefined + return { + displayName: name ?? login ?? fallback.displayName, + auditMetadata: { + boxEnterpriseId: orgId, + ...(login ? { boxServiceAccountLogin: login } : {}), + }, + storedMetadata: { + enterpriseId: orgId, + ...(login ? { serviceAccountLogin: login } : {}), + }, + } + } catch { + return fallback + } +} + +/** + * Mints a Box access token via the Client Credentials Grant, authenticating + * as the Platform App's Service Account (`box_subject_type=enterprise`). + * Credentials ride in the form body (client_secret_post); tokens live ~1 hour + * (honor the response's `expires_in`), carry no refresh token, and are + * re-minted rather than refreshed. + * + * Box reports every CCG auth failure as HTTP 400 with an OAuth2Error body + * (`invalid_client`, `unauthorized_client`, `invalid_grant`), so 4xx maps to + * `invalid_credentials` — except transient 429/408 throttling statuses, which + * map to `provider_unavailable` alongside 5xx/network failures (never blame + * the credentials for provider-side throttling). + */ +export async function mintBoxServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const res = await fetchProvider( + BOX_TOKEN_URL, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: fields.clientSecret, + box_subject_type: 'enterprise', + box_subject_id: fields.orgId, + }).toString(), + }, + 'box_token_mint' + ) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) { + const hint = boxErrorHint(body) + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'box_token_mint', + body, + ...(hint ? { hint } : {}), + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'box_token_mint', + body, + }) + } + + const payload = await parseProviderJson(res, 'box_token_mint') + if (typeof payload.access_token !== 'string' || !payload.access_token) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'box_token_mint', + reason: 'token response missing access_token', + }) + } + + const accessToken = payload.access_token + const expiresInSeconds = typeof payload.expires_in === 'number' ? payload.expires_in : 3600 + + if (options?.skipIdentity) { + return { accessToken, expiresInSeconds } + } + + return { + accessToken, + expiresInSeconds, + identity: await fetchBoxServiceAccountIdentity(accessToken, fields.orgId), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts new file mode 100644 index 00000000000..6e0aad65f22 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts @@ -0,0 +1,320 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' + +const HOST = 'yourorg.my.salesforce.com' +const TOKEN_URL = `https://${HOST}/services/oauth2/token` +const INSTANCE_URL = 'https://yourorg.my.salesforce.com' + +const FIELDS = { clientId: 'test-consumer-key', clientSecret: 'sf-secret', orgId: HOST } + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +function htmlResponse(status: number, body: string): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON') + }, + text: async () => body, + } as unknown as Response +} + +/** Builds a structurally valid unsigned JWT carrying the given exp claim. */ +function jwtWithExp(expSeconds: number): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'none' })}.${encode({ exp: expSeconds })}.sig` +} + +const mockFetch = vi.fn() + +function expectMintCall(expectedUrl = TOKEN_URL): void { + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(expectedUrl) + expect(init.method).toBe('POST') + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const body = new URLSearchParams(init.body as string) + expect(body.get('grant_type')).toBe('client_credentials') + expect(body.get('client_id')).toBe('test-consumer-key') + expect(body.get('client_secret')).toBe('sf-secret') + expect(body.get('scope')).toBeNull() +} + +describe('mintSalesforceServiceAccountToken', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('mints against the My Domain token endpoint and derives identity from userinfo', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'sf-access', + instance_url: INSTANCE_URL, + token_type: 'Bearer', + token_format: 'opaque', + scope: 'api', + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + name: 'Integration User', + preferred_username: 'integration@yourorg.com', + organization_id: '00Dxx0000000001EAA', + }) + ) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result).toEqual({ + accessToken: 'sf-access', + expiresInSeconds: 600, + instanceUrl: INSTANCE_URL, + grantedScopes: ['api'], + identity: { + displayName: 'Integration User', + auditMetadata: { + salesforceMyDomainHost: HOST, + salesforceOrgId: '00Dxx0000000001EAA', + salesforceRunAsUsername: 'integration@yourorg.com', + }, + storedMetadata: { + myDomainHost: HOST, + instanceUrl: INSTANCE_URL, + orgId: '00Dxx0000000001EAA', + runAsUsername: 'integration@yourorg.com', + grantedScopes: 'api', + }, + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + expectMintCall() + expect(mockFetch.mock.calls[1][0]).toBe(`${INSTANCE_URL}/services/oauth2/userinfo`) + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe('Bearer sf-access') + }) + + it('normalizes a pasted URL-style host before minting', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-access' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + const result = await mintSalesforceServiceAccountToken({ + ...FIELDS, + orgId: 'https://YourOrg.My.Salesforce.com/some/path?x=1', + }) + + expectMintCall() + expect(result.instanceUrl).toBe(INSTANCE_URL) + }) + + it.each([ + 'yourorg--uat.sandbox.my.salesforce.com', + 'yourorg-dev-ed.develop.my.salesforce.com', + 'yourorg--sbx.scratch.my.salesforce.com', + ])('accepts the %s partitioned My Domain host', async (host) => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-access' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken({ ...FIELDS, orgId: host }) + + expectMintCall(`https://${host}/services/oauth2/token`) + }) + + it.each([ + 'evil.com', + 'login.salesforce.com', + 'test.salesforce.com', + 'yourorg.my.salesforce.com.evil.com', + 'my.salesforce.com', + 'yourorg.evil.my.salesforce.com', + 'evil.com/yourorg.my.salesforce.com', + 'evil.com#yourorg.my.salesforce.com', + 'yourorg.my.salesforce.mil', + 'yourorg.my.salesforce.com@evil.com', + 'yourorg.my.salesforce.com:8443', + 'yourorg.my.salesforce.com.', + '', + ])('rejects the host %j before any outbound fetch', async (host) => { + await expect( + mintSalesforceServiceAccountToken({ ...FIELDS, orgId: host }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'site_not_found', + status: 400, + logDetail: expect.objectContaining({ step: 'host_validation' }), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + ['invalid_client_id', 'consumer key or consumer secret is invalid'], + ['invalid_client', 'consumer key or consumer secret is invalid'], + [ + 'invalid_grant', + 'Client Credentials Flow is not enabled on the Connected App, no "Run As" user is configured, or the Run As user is deactivated/frozen', + ], + ])('throws invalid_credentials with a hint on 400 %s', async (error, hint) => { + mockFetch.mockResolvedValueOnce(jsonResponse(400, { error, error_description: 'nope' })) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ hint }), + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('skips the userinfo lookup when skipIdentity is set', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL, scope: 'api' }) + ) + + const result = await mintSalesforceServiceAccountToken(FIELDS, { skipIdentity: true }) + + expect(result).toEqual({ + accessToken: 'sf-access', + expiresInSeconds: 600, + instanceUrl: INSTANCE_URL, + grantedScopes: ['api'], + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' })) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + }) + }) + + it('maps a DNS-resolution failure on the My Domain host to site_not_found', async () => { + const dnsError = new TypeError('fetch failed') + ;(dnsError as { cause?: unknown }).cause = Object.assign(new Error('getaddrinfo ENOTFOUND'), { + code: 'ENOTFOUND', + }) + mockFetch.mockRejectedValueOnce(dnsError) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + logDetail: expect.objectContaining({ + reason: 'host does not resolve — check the My Domain host', + }), + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(503, 'maintenance')) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 503, + }) + }) + + it('throws provider_unavailable on a 200 with a non-JSON body', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page')) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when the success body is missing access_token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { instance_url: INSTANCE_URL })) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable on a network error', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + + await expect(mintSalesforceServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('falls back to a host-derived identity when the userinfo call fails', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL }) + ) + .mockRejectedValueOnce(new TypeError('fetch failed')) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.accessToken).toBe('sf-access') + expect(result.identity).toEqual({ + displayName: `Salesforce ${HOST}`, + auditMetadata: { salesforceMyDomainHost: HOST }, + storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL }, + }) + }) + + it('ignores a non-Salesforce instance_url and falls back to the validated host', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-access', instance_url: 'https://evil.com' }) + ) + .mockResolvedValueOnce(jsonResponse(403, {})) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.instanceUrl).toBe(INSTANCE_URL) + expect(mockFetch.mock.calls[1][0]).toBe(`${INSTANCE_URL}/services/oauth2/userinfo`) + }) + + it('clamps the cache TTL to the exp claim when the token is a JWT', async () => { + const exp = Math.floor(Date.now() / 1000) + 300 + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { + access_token: jwtWithExp(exp), + instance_url: INSTANCE_URL, + token_format: 'jwt', + }) + ) + .mockResolvedValueOnce(jsonResponse(403, {})) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.expiresInSeconds).toBeGreaterThan(230) + expect(result.expiresInSeconds).toBeLessThanOrEqual(240) + }) + + it('caps a long-lived JWT exp at the conservative 10-minute TTL', async () => { + const exp = Math.floor(Date.now() / 1000) + 7200 + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: jwtWithExp(exp) })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.expiresInSeconds).toBe(600) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts new file mode 100644 index 00000000000..8928cf9d9c6 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -0,0 +1,271 @@ +import { + normalizeSalesforceMyDomainHost, + SALESFORCE_MY_DOMAIN_HOST_REGEX, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountIdentity, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + fetchProvider, + isTransientProviderStatus, + parseProviderJson, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +/** + * Salesforce never returns `expires_in` on client-credentials responses — + * opaque-token lifetime is the run-as user's org session timeout (2h default, + * 15min minimum), unknowable from the response. Cache conservatively for 10 + * minutes (safely below the 15-minute floor) and rely on re-minting. + */ +const SALESFORCE_TOKEN_TTL_SECONDS = 600 + +interface SalesforceTokenResponse { + access_token?: string + instance_url?: string + scope?: string +} + +interface SalesforceUserinfoResponse { + name?: string + preferred_username?: string + organization_id?: string +} + +/** + * Maps a parsed Salesforce token-endpoint error body to an operator-facing + * hint for server logs. Salesforce returns HTTP 400 for every credential + * problem, so `error`/`error_description` are the only way to tell a bad + * consumer key/secret apart from a misconfigured Connected App. + */ +function salesforceErrorHint(body: string): string | undefined { + try { + const parsed = JSON.parse(body) as { error?: string; error_description?: string } + const error = parsed.error ?? '' + if (error.startsWith('invalid_client')) { + return 'consumer key or consumer secret is invalid' + } + if (error === 'invalid_grant') { + return 'Client Credentials Flow is not enabled on the Connected App, no "Run As" user is configured, or the Run As user is deactivated/frozen' + } + return undefined + } catch { + return undefined + } +} + +/** + * Reads the `exp` claim (epoch seconds) from a JWT-format access token. + * Orgs with "Issue JSON Web Token (JWT)-Based Access Tokens" enabled embed + * the hard expiry in the token itself (never in an `expires_in` field). + * @returns The exp claim, or undefined when the token is not a decodable JWT + */ +function decodeJwtExpSeconds(token: string): number | undefined { + const parts = token.split('.') + if (parts.length !== 3) return undefined + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as { + exp?: number + } + return typeof payload.exp === 'number' ? payload.exp : undefined + } catch { + return undefined + } +} + +/** + * Derives a conservative cache TTL: 10 minutes for opaque tokens (lifetime is + * unknowable from the response), or `exp - 60s` clamped to at most 10 minutes + * when the token is a JWT carrying a hard expiry. + */ +function salesforceTokenTtlSeconds(accessToken: string): number { + const exp = decodeJwtExpSeconds(accessToken) + if (exp === undefined) return SALESFORCE_TOKEN_TTL_SECONDS + const remaining = exp - Math.floor(Date.now() / 1000) - 60 + return Math.min(Math.max(remaining, 0), SALESFORCE_TOKEN_TTL_SECONDS) +} + +/** + * Best-effort identity lookup for the run-as integration user via the + * standard userinfo endpoint. A failure never fails the mint — the caller + * falls back to a host-derived display name. + */ +async function fetchSalesforceIdentity( + accessToken: string, + instanceUrl: string, + host: string +): Promise { + const fallback: ClientCredentialAccountIdentity = { + displayName: `Salesforce ${host}`, + auditMetadata: { salesforceMyDomainHost: host }, + storedMetadata: { myDomainHost: host, instanceUrl }, + } + try { + const res = await fetchProvider( + `${instanceUrl}/services/oauth2/userinfo`, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + 'salesforce_identity' + ) + if (!res.ok) return fallback + const user = await parseProviderJson(res, 'salesforce_identity') + const username = + typeof user.preferred_username === 'string' && user.preferred_username + ? user.preferred_username + : undefined + const name = typeof user.name === 'string' && user.name ? user.name : undefined + const orgId = + typeof user.organization_id === 'string' && user.organization_id + ? user.organization_id + : undefined + return { + displayName: name ?? username ?? fallback.displayName, + auditMetadata: { + salesforceMyDomainHost: host, + ...(orgId ? { salesforceOrgId: orgId } : {}), + ...(username ? { salesforceRunAsUsername: username } : {}), + }, + storedMetadata: { + myDomainHost: host, + instanceUrl, + ...(orgId ? { orgId } : {}), + ...(username ? { runAsUsername: username } : {}), + }, + } + } catch { + return fallback + } +} + +/** + * Mints a Salesforce access token via the OAuth 2.0 Client Credentials Flow + * against the org's own My Domain token endpoint + * (`https://{host}/services/oauth2/token` — login.salesforce.com hard-rejects + * this grant). Credentials ride in the form body (client_secret_post) with no + * scope parameter (Salesforce doesn't support scopes on this endpoint; grants + * come from the Connected App config). The host is SSRF-guarded against the + * My Domain allowlist before any outbound fetch. + * + * Salesforce reports every credential/configuration failure as HTTP 400 with + * `{ error, error_description }` (invalid_client_id, invalid_client, + * invalid_grant), so 4xx maps to `invalid_credentials` — except transient + * 429/408 throttling statuses, which map to `provider_unavailable` alongside + * 5xx/network failures (never blame the credentials for provider-side + * throttling). A host that fails DNS resolution maps to `site_not_found` + * (the pasted My Domain host is wrong, not Salesforce down). The response + * carries no `expires_in` and no refresh token — see + * {@link SALESFORCE_TOKEN_TTL_SECONDS}. + */ +export async function mintSalesforceServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const host = normalizeSalesforceMyDomainHost(fields.orgId) + if (!SALESFORCE_MY_DOMAIN_HOST_REGEX.test(host)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + host, + reason: + 'host is not a Salesforce My Domain host (expected *.my.salesforce.com, *.sandbox.my.salesforce.com, or *.develop.my.salesforce.com — never login.salesforce.com)', + }) + } + + const res = await fetchProvider( + `https://${host}/services/oauth2/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: fields.clientSecret, + }).toString(), + }, + 'salesforce_token_mint', + { + dnsFailureCode: 'site_not_found', + dnsFailureReason: 'host does not resolve — check the My Domain host', + } + ) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) { + const hint = salesforceErrorHint(body) + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'salesforce_token_mint', + host, + body, + ...(hint ? { hint } : {}), + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'salesforce_token_mint', + host, + body, + }) + } + + const payload = await parseProviderJson(res, 'salesforce_token_mint') + if (typeof payload.access_token !== 'string' || !payload.access_token) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'salesforce_token_mint', + host, + reason: 'token response missing access_token', + }) + } + + // The response's instance_url is authoritative (it can differ from the + // stored My Domain host on enhanced-domain orgs), but it is only trusted for + // follow-up fetches when it is an https *.salesforce.com URL — otherwise the + // validated My Domain host is used. + const instanceUrl = normalizeInstanceUrl(payload.instance_url, host) + const grantedScopes = + typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(Boolean) : undefined + + if (options?.skipIdentity) { + return { + accessToken: payload.access_token, + expiresInSeconds: salesforceTokenTtlSeconds(payload.access_token), + instanceUrl, + grantedScopes, + } + } + + const identity = await fetchSalesforceIdentity(payload.access_token, instanceUrl, host) + if (grantedScopes?.length) { + identity.storedMetadata = { + ...identity.storedMetadata, + grantedScopes: grantedScopes.join(' '), + } + } + + return { + accessToken: payload.access_token, + expiresInSeconds: salesforceTokenTtlSeconds(payload.access_token), + instanceUrl, + grantedScopes, + identity, + } +} + +/** + * Validates the mint response's `instance_url` (https, `*.salesforce.com` + * host, no path) and normalizes away any trailing slash; falls back to the + * already-SSRF-validated My Domain host when the value is absent or fails + * validation. + */ +function normalizeInstanceUrl(rawInstanceUrl: string | undefined, host: string): string { + const fallback = `https://${host}` + if (typeof rawInstanceUrl !== 'string' || !rawInstanceUrl) return fallback + try { + const url = new URL(rawInstanceUrl) + if (url.protocol !== 'https:' || !url.hostname.endsWith('.salesforce.com')) return fallback + return `https://${url.hostname}` + } catch { + return fallback + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts new file mode 100644 index 00000000000..dcfd2822a14 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts @@ -0,0 +1,199 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' + +const TOKEN_URL = 'https://zoom.us/oauth/token' + +const FIELDS = { clientId: 'zoom-cid', clientSecret: 'zoom-secret', orgId: 'AbCdEf123' } + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +function htmlResponse(status: number, body: string): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON') + }, + text: async () => body, + } as unknown as Response +} + +const mockFetch = vi.fn() + +function expectMintCall(): void { + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(TOKEN_URL) + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe( + `Basic ${Buffer.from('zoom-cid:zoom-secret').toString('base64')}` + ) + expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const body = new URLSearchParams(init.body as string) + expect(body.get('grant_type')).toBe('account_credentials') + expect(body.get('account_id')).toBe('AbCdEf123') +} + +describe('mintZoomServiceAccountToken', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns the minted token, granted scopes, and derived identity on success', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'zoom-access', + token_type: 'bearer', + expires_in: 3600, + scope: 'meeting:read:meeting:admin user:read:user:admin', + api_url: 'https://api.zoom.us', + }) + ) + + const result = await mintZoomServiceAccountToken(FIELDS) + + expect(result).toEqual({ + accessToken: 'zoom-access', + expiresInSeconds: 3600, + grantedScopes: ['meeting:read:meeting:admin', 'user:read:user:admin'], + identity: { + displayName: 'Zoom account AbCdEf123', + auditMetadata: { zoomAccountId: 'AbCdEf123', zoomClientId: 'zoom-cid' }, + storedMetadata: { + apiUrl: 'https://api.zoom.us', + grantedScopes: 'meeting:read:meeting:admin user:read:user:admin', + }, + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expectMintCall() + }) + + it('omits the identity when skipIdentity is set', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + access_token: 'zoom-access', + expires_in: 3600, + scope: 'meeting:read:meeting:admin', + }) + ) + + const result = await mintZoomServiceAccountToken(FIELDS, { skipIdentity: true }) + + expect(result).toEqual({ + accessToken: 'zoom-access', + expiresInSeconds: 3600, + grantedScopes: ['meeting:read:meeting:admin'], + }) + }) + + it('omits storedMetadata and scopes when the response lacks api_url and scope', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { access_token: 'zoom-access', expires_in: 1800 }) + ) + + const result = await mintZoomServiceAccountToken(FIELDS) + + expect(result.accessToken).toBe('zoom-access') + expect(result.expiresInSeconds).toBe(1800) + expect(result.grantedScopes).toBeUndefined() + expect(result.identity?.storedMetadata).toBeUndefined() + }) + + it('throws invalid_credentials on 400 invalid_client', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { error: 'invalid_client', reason: 'Invalid client_id or client_secret' }) + ) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ hint: 'invalid client_id or client_secret' }), + }) + }) + + it('throws invalid_credentials with a misconfig hint on 400 unsupported grant type', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { reason: 'unsupported grant type', error: 'invalid_request' }) + ) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: expect.objectContaining({ + hint: 'app is not a Server-to-Server OAuth app', + }), + }) + }) + + it('throws invalid_credentials on 401', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'unauthorized' })) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + status: 401, + }) + }) + + it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'rate_limit_exceeded' })) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 429, + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(503, 'unavailable')) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 503, + }) + }) + + it('throws provider_unavailable on a 200 with a non-JSON body', async () => { + mockFetch.mockResolvedValueOnce(htmlResponse(200, 'proxy page')) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when the success body is missing access_token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { token_type: 'bearer' })) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable on a network error', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + + await expect(mintZoomServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts new file mode 100644 index 00000000000..978409ae0ee --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts @@ -0,0 +1,131 @@ +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + fetchProvider, + isTransientProviderStatus, + parseProviderJson, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const ZOOM_TOKEN_URL = 'https://zoom.us/oauth/token' + +interface ZoomTokenResponse { + access_token?: string + expires_in?: number + scope?: string + api_url?: string +} + +/** + * Maps a parsed Zoom token-endpoint error body to an operator-facing hint for + * server logs. Zoom returns HTTP 400 for every credential problem, so the + * `error`/`reason` fields are the only way to tell them apart. + */ +function zoomErrorHint(body: string): string | undefined { + try { + const parsed = JSON.parse(body) as { error?: string; reason?: string } + const haystack = `${parsed.error ?? ''} ${parsed.reason ?? ''}`.toLowerCase() + if (haystack.includes('invalid_client')) { + return 'invalid client_id or client_secret' + } + if (haystack.includes('unsupported grant type')) { + return 'app is not a Server-to-Server OAuth app' + } + return undefined + } catch { + return undefined + } +} + +/** + * Mints a Zoom Server-to-Server OAuth access token via the + * `account_credentials` grant: POST https://zoom.us/oauth/token with HTTP + * Basic auth (client id/secret) and `account_id` = the S2S app's Account ID. + * Tokens live one hour, there is no refresh token, and Zoom allows multiple + * concurrently valid tokens — re-mint instead of refreshing. + * + * Zoom reports every credential failure as HTTP 400 (invalid_client, bad + * account_id, unsupported grant type, deactivated app), so 4xx maps to + * `invalid_credentials` — except transient 429/408 throttling statuses, which + * map to `provider_unavailable` alongside 5xx/network failures (never blame + * the credentials for provider-side throttling). + */ +export async function mintZoomServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const basicAuth = Buffer.from(`${fields.clientId}:${fields.clientSecret}`).toString('base64') + const res = await fetchProvider( + ZOOM_TOKEN_URL, + { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'account_credentials', + account_id: fields.orgId, + }).toString(), + }, + 'zoom_token_mint' + ) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) { + const hint = zoomErrorHint(body) + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'zoom_token_mint', + body, + ...(hint ? { hint } : {}), + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'zoom_token_mint', + body, + }) + } + + const payload = await parseProviderJson(res, 'zoom_token_mint') + if (typeof payload.access_token !== 'string' || !payload.access_token) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'zoom_token_mint', + reason: 'token response missing access_token', + }) + } + + const grantedScopes = + typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(Boolean) : undefined + + if (options?.skipIdentity) { + return { + accessToken: payload.access_token, + expiresInSeconds: typeof payload.expires_in === 'number' ? payload.expires_in : 3600, + grantedScopes, + } + } + + const storedMetadata: Record = {} + if (typeof payload.api_url === 'string' && payload.api_url) { + storedMetadata.apiUrl = payload.api_url + } + if (grantedScopes?.length) { + storedMetadata.grantedScopes = grantedScopes.join(' ') + } + + return { + accessToken: payload.access_token, + expiresInSeconds: typeof payload.expires_in === 'number' ? payload.expires_in : 3600, + grantedScopes, + identity: { + displayName: `Zoom account ${fields.orgId}`, + auditMetadata: { zoomAccountId: fields.orgId, zoomClientId: fields.clientId }, + ...(Object.keys(storedMetadata).length > 0 ? { storedMetadata } : {}), + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts new file mode 100644 index 00000000000..d0ded4e6801 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE } from '@/lib/credentials/client-credential-accounts/descriptors' +import { parseClientCredentialAccountSecretBlob } from '@/lib/credentials/client-credential-accounts/server' + +const MALFORMED = 'Stored client-credential service-account secret is malformed' + +function blob(overrides: Record = {}): string { + return JSON.stringify({ + type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + providerId: 'zoom-service-account', + clientId: 'cid', + clientSecret: 'secret', + orgId: 'org', + ...overrides, + }) +} + +describe('parseClientCredentialAccountSecretBlob', () => { + it('returns the parsed blob when it matches the expected provider', () => { + const parsed = parseClientCredentialAccountSecretBlob(blob(), 'zoom-service-account') + expect(parsed.clientId).toBe('cid') + expect(parsed.orgId).toBe('org') + }) + + it('throws the clean malformed error on a non-JSON payload (not a raw SyntaxError)', () => { + expect(() => + parseClientCredentialAccountSecretBlob('not json {', 'zoom-service-account') + ).toThrow(MALFORMED) + }) + + it('rejects a blob whose providerId does not match the credential row', () => { + expect(() => parseClientCredentialAccountSecretBlob(blob(), 'box-service-account')).toThrow( + MALFORMED + ) + }) + + it('rejects a blob with the wrong discriminator type', () => { + expect(() => + parseClientCredentialAccountSecretBlob( + blob({ type: 'token_service_account' }), + 'zoom-service-account' + ) + ).toThrow(MALFORMED) + }) + + it('rejects a blob missing a required secret field', () => { + expect(() => + parseClientCredentialAccountSecretBlob(blob({ clientSecret: '' }), 'zoom-service-account') + ).toThrow(MALFORMED) + }) + + it('throws the clean malformed error on a JSON-null payload', () => { + expect(() => parseClientCredentialAccountSecretBlob('null', 'zoom-service-account')).toThrow( + MALFORMED + ) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts new file mode 100644 index 00000000000..1e92d09d5b1 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -0,0 +1,125 @@ +import { + BOX_SERVICE_ACCOUNT_PROVIDER_ID, + CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + type ClientCredentialAccountProviderId, + isClientCredentialAccountProviderId, + SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, + ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' +import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' +import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' + +/** Raw fields a client-credential minter receives (already trimmed). */ +export interface ClientCredentialAccountFields { + clientId: string + clientSecret: string + /** Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, Salesforce My Domain host). */ + orgId: string +} + +/** Identity derived from a successful mint, used at connect time. */ +export interface ClientCredentialAccountIdentity { + /** Default display name when the user didn't provide one. */ + displayName: string + /** Non-secret identifiers recorded in the audit log (e.g. account/enterprise id). */ + auditMetadata: Record + /** + * Non-secret metadata persisted inside the encrypted blob alongside the + * credentials (e.g. regional API host, service-account login) for debugging. + */ + storedMetadata?: Record +} + +/** Result of a successful client-credentials token mint. */ +export interface ClientCredentialAccountMintResult { + accessToken: string + expiresInSeconds: number + /** + * Provider API base URL the minted token must be used against (Salesforce + * `instance_url`), forwarded to tools alongside the token. + */ + instanceUrl?: string + /** Scopes granted to the app, when the provider reports them. */ + grantedScopes?: string[] + identity?: ClientCredentialAccountIdentity +} + +/** Options controlling how much work a mint performs. */ +export interface ClientCredentialAccountMintOptions { + /** + * Skips the best-effort identity lookup (extra provider round-trip on Box + * and Salesforce). Execution-time token resolution discards `identity`, so + * it passes `skipIdentity: true`; connect-time verification keeps the + * lookup for the display name and audit metadata. + */ + skipIdentity?: boolean +} + +export type ClientCredentialAccountMinter = ( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +) => Promise + +/** + * Server-side minting registry for client-credential providers. Keys must stay + * in lockstep with `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS` — a descriptor + * without a minter fails loudly at create time. The same minter runs at + * connect time (verification) and at execution time (token resolution). + */ +const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< + ClientCredentialAccountProviderId, + ClientCredentialAccountMinter +> = { + [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomServiceAccountToken, + [BOX_SERVICE_ACCOUNT_PROVIDER_ID]: mintBoxServiceAccountToken, + [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken, +} + +export function getClientCredentialAccountMinter( + providerId: string +): ClientCredentialAccountMinter | undefined { + return isClientCredentialAccountProviderId(providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_MINTERS[providerId] + : undefined +} + +/** + * Shape of the decrypted secret blob persisted for client-credential accounts. + * `providerId` is stored inside the blob so a mismatched credential row fails + * loudly at resolution time instead of minting against another provider. + */ +export interface ClientCredentialAccountSecretBlob { + type: typeof CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE + providerId: string + clientId: string + clientSecret: string + orgId: string + metadata?: Record +} + +export function parseClientCredentialAccountSecretBlob( + decrypted: string, + expectedProviderId: string +): ClientCredentialAccountSecretBlob { + const malformed = new Error('Stored client-credential service-account secret is malformed') + let parsed: ClientCredentialAccountSecretBlob + try { + parsed = JSON.parse(decrypted) as ClientCredentialAccountSecretBlob + } catch { + throw malformed + } + if (typeof parsed !== 'object' || parsed === null) { + throw malformed + } + if ( + parsed.type !== CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE || + parsed.providerId !== expectedProviderId || + !parsed.clientId || + !parsed.clientSecret || + !parsed.orgId + ) { + throw malformed + } + return parsed +} diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 1c6d5ff2d87..1298219df66 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -1,8 +1,9 @@ import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' +import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' +import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') @@ -16,29 +17,63 @@ export async function createConnectDraft(params: { userId: string workspaceId: string providerId: string + /** Reconnect only: the existing credential the callback should rebind instead of creating a new one. */ + credentialId?: string + /** Reconnect only: the credential's actual name, so audit records stay accurate. */ + displayName?: string }): Promise { - const { userId, workspaceId, providerId } = params - const service = getAllOAuthServices().find((candidate) => candidate.providerId === providerId) - - let displayName = service?.name ?? providerId - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${displayName}` + const { userId, workspaceId, providerId, credentialId } = params + + let displayName = params.displayName + if (!displayName) { + const service = getAllOAuthServices().find((s) => s.providerId === providerId) + const serviceName = service?.name ?? providerId + + let userName: string | null = null + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + userName = row?.name ?? null + } catch (error) { + // Cosmetic only — fall back to the "My {Service}" default + logger.warn('User name lookup failed for connect draft display name', { + userId, + workspaceId, + providerId, + error, + }) + } + + // Auto-number against existing workspace credentials so repeat connects for + // the same provider stay distinguishable — same behavior as the connect + // modal, which computes this client-side. Best effort: on failure the name + // simply skips deduplication. + let takenNames: ReadonlySet = new Set() + try { + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) + } catch (error) { + // Cosmetic only — proceed without collision numbering + logger.warn('Credential name lookup failed for connect draft deduplication', { + userId, + workspaceId, + providerId, + error, + }) } - } catch { - // Fall back to the service name. + + displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } const now = new Date() const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) - await db .delete(pendingCredentialDraft) .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db .insert(pendingCredentialDraft) .values({ @@ -47,6 +82,7 @@ export async function createConnectDraft(params: { workspaceId, providerId, displayName, + credentialId: credentialId ?? null, expiresAt, createdAt: now, }) @@ -56,8 +92,16 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: { displayName, expiresAt, createdAt: now }, + // credentialId must be written on BOTH paths: a plain connect that reuses a + // stale reconnect draft row would otherwise silently rebind the old + // credential instead of creating a new one. + set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) - logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) + logger.info('Created OAuth connect credential draft', { + userId, + workspaceId, + providerId, + credentialId: credentialId ?? null, + }) } diff --git a/apps/sim/lib/credentials/display-name.test.ts b/apps/sim/lib/credentials/display-name.test.ts new file mode 100644 index 00000000000..36d23dfc8b9 --- /dev/null +++ b/apps/sim/lib/credentials/display-name.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + DISPLAY_NAME_MAX_LENGTH, + defaultCredentialDisplayName, +} from '@/lib/credentials/display-name' + +const NONE: ReadonlySet = new Set() + +describe('defaultCredentialDisplayName', () => { + it("produces {Name}'s {Service} when the user name is known", () => { + expect(defaultCredentialDisplayName('Justin', 'Gmail', NONE)).toBe("Justin's Gmail") + }) + + it('trims surrounding whitespace from the user name', () => { + expect(defaultCredentialDisplayName(' Justin ', 'Gmail', NONE)).toBe("Justin's Gmail") + }) + + it.each([null, undefined, '', ' '])('falls back to My {Service} for user name %j', (name) => { + expect(defaultCredentialDisplayName(name, 'Gmail', NONE)).toBe('My Gmail') + }) + + it('appends " 2" when the base name is taken', () => { + const taken = new Set(["justin's gmail"]) + expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 2") + }) + + it('skips taken numbered names and picks the next free slot', () => { + const taken = new Set(["justin's gmail", "justin's gmail 2", "justin's gmail 3"]) + expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 4") + }) + + it('compares collisions case-insensitively', () => { + const taken = new Set(["justin's gmail"]) + expect(defaultCredentialDisplayName('JUSTIN', 'Gmail', taken)).toBe("JUSTIN's Gmail 2") + }) + + it('numbers the My {Service} fallback on collision too', () => { + const taken = new Set(['my gmail']) + expect(defaultCredentialDisplayName(null, 'Gmail', taken)).toBe('My Gmail 2') + }) + + it('truncates a long user name so name + suffix + disambiguator fit the max length', () => { + const longName = 'x'.repeat(400) + const result = defaultCredentialDisplayName(longName, 'Gmail', NONE) + + expect(result.endsWith("'s Gmail")).toBe(true) + expect(result.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) + + const taken = new Set([result.toLowerCase()]) + const numbered = defaultCredentialDisplayName(longName, 'Gmail', taken) + expect(numbered).toBe(`${result} 2`) + expect(numbered.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) + }) +}) diff --git a/apps/sim/lib/credentials/display-name.ts b/apps/sim/lib/credentials/display-name.ts new file mode 100644 index 00000000000..9b946f31e56 --- /dev/null +++ b/apps/sim/lib/credentials/display-name.ts @@ -0,0 +1,49 @@ +/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ +export const DISPLAY_NAME_MAX_LENGTH = 255 + +/** + * Reserved tail budget when truncating the username so the auto-numbering + * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. + */ +const COLLISION_SUFFIX_RESERVATION = 5 + +/** Upper bound for the auto-numbering search — pathological if ever reached. */ +const MAX_COLLISION_INDEX = 10000 + +/** + * Default credential display name. Produces `"{Name}'s {Service}"` when the + * user's name is known, falling back to `"My {Service}"` otherwise. The + * username is truncated so the full string (including any auto-numbering + * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. + * + * When the base name collides with an existing credential in `takenNames`, + * `" 2"`, `" 3"`, ... are appended until an unused name is found. `takenNames` + * must contain lowercased names; comparison is case-insensitive to match the + * duplicate-detection in the connect modal. + */ +export function defaultCredentialDisplayName( + userName: string | null | undefined, + serviceName: string, + takenNames: ReadonlySet +): string { + const trimmed = userName?.trim() + let base: string + if (trimmed) { + const suffix = `'s ${serviceName}` + const nameBudget = Math.max( + 0, + DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION + ) + const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed + base = `${safeName}${suffix}` + } else { + base = `My ${serviceName}` + } + + if (!takenNames.has(base.toLowerCase())) return base + for (let n = 2; n < MAX_COLLISION_INDEX; n++) { + const candidate = `${base} ${n}` + if (!takenNames.has(candidate.toLowerCase())) return candidate + } + return base +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index b2812a7e6b6..a26a190f126 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -49,6 +49,10 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { /** Atlassian service-account secret rotation (reconnect). */ apiToken?: string domain?: string + /** Client-credential service-account secret rotation (reconnect). */ + clientId?: string + clientSecret?: string + orgId?: string } export interface PerformCredentialResult { @@ -59,6 +63,7 @@ export interface PerformCredentialResult { providerErrorCode?: string workspaceId?: string updatedFields?: string[] + previousDisplayName?: string } export async function performUpdateCredential( @@ -125,7 +130,10 @@ export async function performUpdateCredential( params.signingSecret !== undefined || params.botToken !== undefined || params.apiToken !== undefined || - params.domain !== undefined + params.domain !== undefined || + params.clientId !== undefined || + params.clientSecret !== undefined || + params.orgId !== undefined let rotatedSlackBotUserId: string | undefined if (hasRotationSecret && access.credential.type === 'service_account') { try { @@ -136,6 +144,9 @@ export async function performUpdateCredential( botToken: params.botToken, apiToken: params.apiToken, domain: params.domain, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, } ) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey @@ -213,7 +224,12 @@ export async function performUpdateCredential( request: params.request, }) - return { success: true, workspaceId: access.credential.workspaceId, updatedFields } + return { + success: true, + workspaceId: access.credential.workspaceId, + updatedFields, + previousDisplayName: access.credential.displayName, + } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { return { diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index ea505ddd9c4..e636edde4cd 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,3 +1,4 @@ +import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, @@ -12,6 +13,9 @@ export type ServiceAccountFieldId = | 'serviceAccountJson' | 'signingSecret' | 'botToken' + | 'clientId' + | 'clientSecret' + | 'orgId' /** * Required create-body fields per service-account provider — the client-safe @@ -19,7 +23,8 @@ export type ServiceAccountFieldId = * (Server-side builders re-derive their own requirements: token-paste * providers from descriptor fields, bespoke providers inline.) Token-paste * providers contribute their entries from - * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are + * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`, client-credential providers from + * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are * declared here. */ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { @@ -27,6 +32,7 @@ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record ({ - // Identity encryption so tests can read back the JSON blob. - mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), - mockFetchSlackTeamId: vi.fn(), - mockValidateAtlassian: vi.fn(), - mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), - })) +const { + mockEncryptSecret, + mockFetchSlackTeamId, + mockValidateAtlassian, + mockNormalizeDomain, + mockClientCredentialMinter, +} = vi.hoisted(() => ({ + // Identity encryption so tests can read back the JSON blob. + mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), + mockFetchSlackTeamId: vi.fn(), + mockValidateAtlassian: vi.fn(), + mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), + mockClientCredentialMinter: vi.fn(), +})) vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId })) @@ -33,6 +39,12 @@ vi.mock('@/lib/api/contracts/credentials', () => ({ vi.mock('@/lib/api/server', () => ({ getValidationErrorMessage: (_error: unknown, fallback: string) => fallback, })) +vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ + getClientCredentialAccountMinter: (providerId: string) => + providerId === 'zoom-service-account' || providerId === 'box-service-account' + ? mockClientCredentialMinter + : undefined, +})) import { ServiceAccountSecretError, @@ -139,6 +151,74 @@ describe('verifyAndBuildServiceAccountSecret', () => { ).rejects.toThrow('Unsupported service-account provider') }) + it('dispatches a client-credential provider to the minter and encrypts the blob', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'minted', + expiresInSeconds: 3600, + identity: { + displayName: 'Zoom account acc-1', + auditMetadata: { zoomAccountId: 'acc-1' }, + storedMetadata: { apiUrl: 'https://api.zoom.us' }, + }, + }) + const result = await verifyAndBuildServiceAccountSecret('zoom-service-account', { + clientId: ' cid ', + clientSecret: ' csec ', + orgId: ' acc-1 ', + }) + expect(result.providerId).toBe('zoom-service-account') + expect(result.displayName).toBe('Zoom account acc-1') + expect(result.auditMetadata).toEqual({ zoomAccountId: 'acc-1' }) + expect(mockClientCredentialMinter).toHaveBeenCalledWith({ + clientId: 'cid', + clientSecret: 'csec', + orgId: 'acc-1', + }) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toEqual({ + type: 'client_credential_account', + providerId: 'zoom-service-account', + clientId: 'cid', + clientSecret: 'csec', + orgId: 'acc-1', + metadata: { apiUrl: 'https://api.zoom.us' }, + }) + }) + + it('falls back to a label-derived display name when the mint has no identity', async () => { + mockClientCredentialMinter.mockResolvedValue({ accessToken: 'minted', expiresInSeconds: 3600 }) + const result = await verifyAndBuildServiceAccountSecret('box-service-account', { + clientId: 'cid', + clientSecret: 'csec', + orgId: '999', + }) + expect(result.displayName).toBe('Box 999') + expect(result.auditMetadata).toEqual({}) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob.metadata).toBeUndefined() + }) + + it('throws when client-credential required fields are missing, without minting', async () => { + await expect( + verifyAndBuildServiceAccountSecret('zoom-service-account', { + clientId: 'cid', + clientSecret: 'csec', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(mockClientCredentialMinter).not.toHaveBeenCalled() + }) + + it('propagates a failed client-credential mint', async () => { + mockClientCredentialMinter.mockRejectedValue(new Error('invalid_credentials')) + await expect( + verifyAndBuildServiceAccountSecret('box-service-account', { + clientId: 'cid', + clientSecret: 'bad', + orgId: '999', + }) + ).rejects.toThrow('invalid_credentials') + }) + it('rejects prototype-chain providerIds with a validation error, not a TypeError', async () => { for (const providerId of ['__proto__', 'constructor', 'toString']) { await expect( diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index a14f0d4b5ca..3562658a1d6 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -6,6 +6,15 @@ import { normalizeAtlassianDomain, validateAtlassianServiceAccount, } from '@/lib/credentials/atlassian-service-account' +import { + CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + getClientCredentialAccountDescriptor, + isClientCredentialAccountProviderId, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + type ClientCredentialAccountSecretBlob, + getClientCredentialAccountMinter, +} from '@/lib/credentials/client-credential-accounts/server' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -31,6 +40,9 @@ export interface ServiceAccountSecretFields { apiToken?: string domain?: string serviceAccountJson?: string + clientId?: string + clientSecret?: string + orgId?: string } export interface ServiceAccountSecretResult { @@ -200,6 +212,52 @@ async function buildTokenServiceAccountSecret( } } +/** + * Builds a client-credential service-account secret (OAuth client id/secret + + * provider org identifier) for any provider registered in + * `CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS`: verifies the triple by minting a + * real access token via the provider's registered minter (also capturing the + * derived identity for the display name and audit log), then persists the raw + * fields in the encrypted blob so execution-time resolution can re-mint. + */ +async function buildClientCredentialAccountSecret( + providerId: string, + fields: ServiceAccountSecretFields +): Promise { + const descriptor = getClientCredentialAccountDescriptor(providerId) + const minter = getClientCredentialAccountMinter(providerId) + if (!descriptor || !minter) { + throw new ServiceAccountSecretError( + `No minter registered for service-account provider ${providerId}` + ) + } + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const orgId = fields.orgId?.trim() + if (!clientId || !clientSecret || !orgId) { + const required = descriptor.fields.map((field) => field.id).join(', ') + throw new ServiceAccountSecretError( + `${required} are required for ${descriptor.serviceLabel} service account credentials` + ) + } + const mint = await minter({ clientId, clientSecret, orgId }) + const blob: ClientCredentialAccountSecretBlob = { + type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + providerId, + clientId, + clientSecret, + orgId, + ...(mint.identity?.storedMetadata ? { metadata: mint.identity.storedMetadata } : {}), + } + const { encrypted } = await encryptSecret(JSON.stringify(blob)) + return { + providerId, + encryptedServiceAccountKey: encrypted, + displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${orgId}`, + auditMetadata: mint.identity?.auditMetadata ?? {}, + } +} + type ServiceAccountSecretBuilder = ( fields: ServiceAccountSecretFields ) => Promise @@ -219,8 +277,9 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record`); `x-api-token` providers (e.g. + * Pipedrive) send the token in an `x-api-token` header instead, and the + * token route surfaces this so tool header builders can switch schemes. + */ + authStyle?: 'bearer' | 'x-api-token' } export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const @@ -53,6 +60,7 @@ export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' a export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const export const ASANA_SERVICE_ACCOUNT_PROVIDER_ID = 'asana-service-account' as const export const ATTIO_SERVICE_ACCOUNT_PROVIDER_ID = 'attio-service-account' as const +export const CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID = 'clickup-service-account' as const export const LINEAR_SERVICE_ACCOUNT_PROVIDER_ID = 'linear-service-account' as const export const MONDAY_SERVICE_ACCOUNT_PROVIDER_ID = 'monday-service-account' as const export const SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID = 'shopify-service-account' as const @@ -60,6 +68,7 @@ export const WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'webflow-service-account' as export const TRELLO_SERVICE_ACCOUNT_PROVIDER_ID = 'trello-service-account' as const export const CALCOM_SERVICE_ACCOUNT_PROVIDER_ID = 'calcom-service-account' as const export const WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID = 'wealthbox-service-account' as const +export const PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'pipedrive-service-account' as const const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i @@ -69,6 +78,7 @@ export type TokenServiceAccountProviderId = | typeof NOTION_SERVICE_ACCOUNT_PROVIDER_ID | typeof ASANA_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATTIO_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID | typeof LINEAR_SERVICE_ACCOUNT_PROVIDER_ID | typeof MONDAY_SERVICE_ACCOUNT_PROVIDER_ID | typeof SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID @@ -76,6 +86,7 @@ export type TokenServiceAccountProviderId = | typeof TRELLO_SERVICE_ACCOUNT_PROVIDER_ID | typeof CALCOM_SERVICE_ACCOUNT_PROVIDER_ID | typeof WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID + | typeof PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< TokenServiceAccountProviderId, @@ -172,6 +183,23 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< helpText: 'Check the scopes granted to the key in Attio — tools whose scopes are missing will fail at run time.', }, + [CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ClickUp', + tokenNoun: 'personal API token', + connectNoun: 'API token', + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'pk_...', + secret: true, + hintPattern: /^pk_/, + hintMessage: 'ClickUp personal API tokens start with pk_.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/clickup-service-account', + }, [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Linear', @@ -304,6 +332,24 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< helpText: 'Trial accounts cannot use the Wealthbox API; contact Wealthbox support if API Access is missing from your Settings.', }, + [PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Pipedrive', + tokenNoun: 'API token', + connectNoun: 'API token', + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste personal API token', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/pipedrive-service-account', + helpText: + 'Each Pipedrive user has one API token per company — regenerating it breaks every integration using the old value, and API-token traffic gets lower rate limits than OAuth.', + authStyle: 'x-api-token', + }, } /** diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 1c8e1114531..abf0b003b96 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -23,22 +23,55 @@ export class TokenServiceAccountValidationError extends Error { const ERROR_SNIPPET_MAX_LENGTH = 500 +/** + * Transient statuses a provider token/verification endpoint can return that + * say nothing about the submitted credentials (throttling, request timeout) — + * they must map to `provider_unavailable`, never `invalid_credentials`. + */ +export function isTransientProviderStatus(status: number): boolean { + return status === 408 || status === 429 +} + +export interface FetchProviderOptions { + /** + * Validation code thrown when the host does not resolve (`ENOTFOUND` only — + * the transient `EAI_AGAIN` stays `provider_unavailable`). For user-supplied + * hosts (e.g. a Salesforce My Domain), a non-resolving host means the pasted + * host is wrong — not that the provider is down — so callers map it to + * `site_not_found`. + */ + dnsFailureCode?: TokenServiceAccountValidationCode + /** Log-detail reason accompanying a DNS-resolution failure. */ + dnsFailureReason?: string +} + /** * Fetches a provider verification endpoint, mapping network-level failures * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape * as raw undici errors — whose `cause` can carry connection details — and are - * never blamed on the pasted token. + * never blamed on the pasted token. DNS-resolution failures can optionally be + * mapped to a different code via {@link FetchProviderOptions}. */ const PROVIDER_FETCH_TIMEOUT_MS = 10_000 export async function fetchProvider( url: string, init: RequestInit, - step: string + step: string, + options?: FetchProviderOptions ): Promise { try { return await fetch(url, { ...init, signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) }) - } catch { + } catch (error) { + const causeCode = (error as { cause?: { code?: unknown } })?.cause?.code + // Only ENOTFOUND proves the host doesn't exist; EAI_AGAIN is a transient + // resolver failure and stays provider_unavailable. + if (options?.dnsFailureCode && causeCode === 'ENOTFOUND') { + throw new TokenServiceAccountValidationError(options.dnsFailureCode, 400, { + step, + reason: options.dnsFailureReason ?? 'host does not resolve', + }) + } throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step, reason: 'network error reaching provider', diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index c0a2ed16d1d..9f01c3c0027 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -3,11 +3,13 @@ import { ASANA_SERVICE_ACCOUNT_PROVIDER_ID, ATTIO_SERVICE_ACCOUNT_PROVIDER_ID, CALCOM_SERVICE_ACCOUNT_PROVIDER_ID, + CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, isTokenServiceAccountProviderId, LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, MONDAY_SERVICE_ACCOUNT_PROVIDER_ID, NOTION_SERVICE_ACCOUNT_PROVIDER_ID, + PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID, SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, type TokenServiceAccountProviderId, @@ -19,10 +21,12 @@ import { validateAirtableServiceAccount } from '@/lib/credentials/token-service- import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana' import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio' import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom' +import { validateClickupServiceAccount } from '@/lib/credentials/token-service-accounts/validators/clickup' import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot' import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear' import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday' import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion' +import { validatePipedriveServiceAccount } from '@/lib/credentials/token-service-accounts/validators/pipedrive' import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello' import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox' @@ -67,6 +71,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: validateNotionServiceAccount, [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAsanaServiceAccount, [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: validateAttioServiceAccount, + [CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID]: validateClickupServiceAccount, [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: validateLinearServiceAccount, [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateMondayServiceAccount, [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: validateShopifyServiceAccount, @@ -74,6 +79,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: validateTrelloServiceAccount, [CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalcomServiceAccount, [WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: validateWealthboxServiceAccount, + [PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePipedriveServiceAccount, } export function getTokenServiceAccountValidator( diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts new file mode 100644 index 00000000000..129fbb1fb02 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -0,0 +1,73 @@ +import { + fetchProvider, + parseProviderJson, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const CLICKUP_USER_URL = 'https://api.clickup.com/api/v2/user' + +interface ClickUpUserResponse { + user?: { + id?: number + username?: string | null + email?: string | null + } +} + +/** + * Validates a ClickUp personal API token by fetching the authorized user. The + * Authorization header is built with the same helper the runtime tools use + * (`clickupAuthorizationHeader`): personal `pk_` tokens go bare, anything else + * gets the `Bearer` prefix — so validation exercises the exact header shape + * tools will send. + */ +export async function validateClickupServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetchProvider( + CLICKUP_USER_URL, + { + method: 'GET', + headers: { + Authorization: clickupAuthorizationHeader(fields.apiToken), + 'Content-Type': 'application/json', + }, + }, + 'user' + ) + + if (!res.ok) { + const body = await readProviderErrorSnippet(res) + if (res.status === 401 || res.status === 403) { + throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { + step: 'user', + body, + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { + step: 'user', + body, + }) + } + + const payload = await parseProviderJson(res, 'user') + const user = payload.user + if (!user?.id) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'user', + reason: 'missing user in response', + }) + } + + return { + displayName: user.username || user.email || 'ClickUp account', + auditMetadata: { clickupUserId: String(user.id) }, + storedMetadata: { userId: String(user.id) }, + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts new file mode 100644 index 00000000000..1e73129649b --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { validatePipedriveServiceAccount } from '@/lib/credentials/token-service-accounts/validators/pipedrive' + +const ME_URL = 'https://api.pipedrive.com/v1/users/me' + +const FIELDS = { apiToken: 'pd-test-token' } + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +const mockFetch = vi.fn() + +describe('validatePipedriveServiceAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns displayName and metadata on success using the x-api-token header', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { + id: 42, + name: 'Jane Doe', + company_id: 777, + company_name: 'Acme Inc', + company_domain: 'acme', + }, + }) + ) + + const result = await validatePipedriveServiceAccount(FIELDS) + + expect(result).toEqual({ + displayName: 'Jane Doe (Acme Inc)', + auditMetadata: { pipedriveCompanyId: '777' }, + storedMetadata: { userId: '42', companyId: '777', companyDomain: 'acme' }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(ME_URL) + expect(init.headers['x-api-token']).toBe(FIELDS.apiToken) + expect(init.headers.Authorization).toBeUndefined() + }) + + it('falls back to a company display name when the user name is missing', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, data: { id: 42, company_id: 777 } }) + ) + + const result = await validatePipedriveServiceAccount(FIELDS) + + expect(result.displayName).toBe('Pipedrive company 777') + expect(result.storedMetadata).toEqual({ userId: '42', companyId: '777' }) + }) + + it('throws invalid_credentials on 401', async () => { + mockFetch.mockResolvedValue(jsonResponse(401, {})) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 401, + }) + }) + + it('throws provider_unavailable on 429 (rate limited, token not blamed)', async () => { + mockFetch.mockResolvedValue(jsonResponse(429, { error: 'Rate limit exceeded' })) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 429, + }) + }) + + it('throws provider_unavailable on 503', async () => { + mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' })) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 503, + }) + }) + + it('throws provider_unavailable on a non-JSON response body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: '', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON') + }, + text: async () => 'proxy error', + } as unknown as Response) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + }) + }) + + it('throws provider_unavailable when the response lacks a user id', async () => { + mockFetch.mockResolvedValue(jsonResponse(200, { success: true, data: {} })) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + logDetail: { step: 'users_me', reason: 'missing user id' }, + }) + }) + + it('throws provider_unavailable on a network error', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')) + + await expect(validatePipedriveServiceAccount(FIELDS)).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + status: 502, + logDetail: { step: 'users_me', reason: 'network error reaching provider' }, + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts new file mode 100644 index 00000000000..3fa20e90ec6 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts @@ -0,0 +1,82 @@ +import { + fetchProvider, + parseProviderJson, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' + +const USERS_ME_URL = 'https://api.pipedrive.com/v1/users/me' + +interface PipedriveUsersMeResponse { + success?: boolean + data?: { + id?: number + name?: string + company_id?: number + company_name?: string + company_domain?: string + } +} + +/** + * Validates a Pipedrive personal API token via `GET /v1/users/me` with the + * `x-api-token` header — the exact header shape the Sim Pipedrive tools use + * for API-token credentials, so validation exercises the same auth path as + * execution. Error mapping is status-based only (the 401 body envelope is + * undocumented): 401/403 → `invalid_credentials`, everything else non-2xx + * (including 429 rate limiting) → `provider_unavailable` via the shared + * helpers. + */ +export async function validatePipedriveServiceAccount( + fields: TokenServiceAccountFields +): Promise { + const res = await fetchProvider( + USERS_ME_URL, + { + headers: { + 'x-api-token': fields.apiToken, + Accept: 'application/json', + }, + }, + 'users_me' + ) + await throwForProviderResponse(res, 'users_me') + + const payload = await parseProviderJson(res, 'users_me') + const user = payload.data + if (payload.success !== true || typeof user?.id !== 'number') { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'users_me', + reason: payload.success !== true ? 'success flag missing in response' : 'missing user id', + }) + } + + const userName = typeof user.name === 'string' && user.name ? user.name : undefined + const companyName = + typeof user.company_name === 'string' && user.company_name ? user.company_name : undefined + const companyId = typeof user.company_id === 'number' ? String(user.company_id) : undefined + const companyDomain = + typeof user.company_domain === 'string' && user.company_domain ? user.company_domain : undefined + + const storedMetadata: Record = { userId: String(user.id) } + if (companyId) storedMetadata.companyId = companyId + if (companyDomain) storedMetadata.companyDomain = companyDomain + + const displayName = userName + ? companyName + ? `${userName} (${companyName})` + : userName + : companyId + ? `Pipedrive company ${companyId}` + : 'Pipedrive API token' + + return { + displayName, + auditMetadata: companyId ? { pipedriveCompanyId: companyId } : {}, + storedMetadata, + } +} diff --git a/apps/sim/lib/execution/e2b.test.ts b/apps/sim/lib/execution/e2b.test.ts index 738d1fdfff0..0bc92f095f2 100644 --- a/apps/sim/lib/execution/e2b.test.ts +++ b/apps/sim/lib/execution/e2b.test.ts @@ -88,6 +88,28 @@ describe('e2b sandbox inputs', () => { expect(fetchCall?.[1].user).toBe('root') }) + it('passes the requested timeout to shell execution and returns timeout failures', async () => { + mockCommandsRun.mockRejectedValueOnce( + Object.assign(new Error('Execution timed out'), { + stderr: 'Execution timed out', + exitCode: 1, + }) + ) + + const result = await executeShellInE2B({ + code: 'sleep 60', + envs: {}, + timeoutMs: 7000, + }) + + expect(mockCommandsRun).toHaveBeenCalledWith( + 'sleep 60', + expect.objectContaining({ timeoutMs: 7000 }) + ) + expect(result.error).toContain('Execution timed out') + expect(mockKill).toHaveBeenCalled() + }) + it('throws a clear error and kills the sandbox when the fetch fails', async () => { mockCommandsRun.mockRejectedValueOnce(new Error('curl: (22) 403')) @@ -106,3 +128,171 @@ describe('e2b sandbox inputs', () => { expect(mockRunCode).not.toHaveBeenCalled() }) }) + +describe('e2b result marker extraction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockResolvedValue({ + sandboxId: 'sb_1', + files: { write: mockFilesWrite }, + commands: { run: mockCommandsRun }, + runCode: mockRunCode, + kill: mockKill, + }) + mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) + }) + + it('parses the result marker from a single stdout entry', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: ['before\n', `__SIM_RESULT__=${JSON.stringify({ ok: true })}\n`] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual({ ok: true }) + expect(res.stdout).toBe('before') + }) + + it('reassembles a marker line split across stream chunks (large single-line result)', async () => { + const payload = 'x'.repeat(50_000) + const markerLine = `__SIM_RESULT__=${JSON.stringify(payload)}\n` + // The kernel splits one long line across several stream messages; each + // chunk is NOT newline-terminated except the last. + const chunks = [ + 'log line\n', + markerLine.slice(0, 20_000), + markerLine.slice(20_000, 40_000), + markerLine.slice(40_000), + ] + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: chunks }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toBe(payload) + expect(res.stdout).toBe('log line') + }) + + it('returns an error instead of a truncated fragment when the marker payload is corrupted', async () => { + // A genuinely broken payload (e.g. the tail chunk was lost entirely). + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: [`__SIM_RESULT__="${'x'.repeat(100)}\n`] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toMatch(/corrupted in transport/) + expect(res.result).toBeNull() + }) + + it('parses the shell marker and falls back to the raw string for non-JSON payloads', async () => { + mockCommandsRun.mockResolvedValueOnce({ + stdout: `hello\n__SIM_RESULT__=${JSON.stringify([1, 2])}\n`, + stderr: '', + exitCode: 0, + }) + const ok = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) + expect(ok.error).toBeUndefined() + expect(ok.result).toEqual([1, 2]) + expect(ok.stdout).toBe('hello') + + // Shell markers are user-authored (`echo "__SIM_RESULT__=$STATUS"`), so a + // plain non-JSON value is a string result, never a transport error. + mockCommandsRun.mockResolvedValueOnce({ + stdout: '__SIM_RESULT__=ok\n', + stderr: '', + exitCode: 0, + }) + const plain = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) + expect(plain.error).toBeUndefined() + expect(plain.result).toBe('ok') + }) + + it('takes the LAST marker line so user-printed marker lines cannot shadow the real result', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { + stdout: [ + '__SIM_RESULT__=user-debug-junk\n', + `__SIM_RESULT__=${JSON.stringify({ real: true })}\n`, + ], + }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual({ real: true }) + expect(res.stdout).not.toContain('__SIM_RESULT__') + }) + + it('finds a marker that landed on stderr', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { + stdout: ['regular output\n'], + stderr: [`__SIM_RESULT__=${JSON.stringify([1])}\n`], + }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual([1]) + expect(res.stdout).not.toContain('__SIM_RESULT__') + }) + + it('keeps separate print lines intact (chunks concatenated verbatim, not newline-joined)', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: ['a\n', 'b\n'], stderr: ['warn\n'] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.result).toBeNull() + expect(res.stdout).toBe('a\nb\n\nwarn\n') + }) +}) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 21c50af09b5..0f9f2e3a553 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -140,6 +140,59 @@ async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise< return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) } +/** + * Marker prefix for the serialized code result printed to stdout. Emitters + * (the wrapper builders in the function-execute route) interpolate this + * constant so producer and parser cannot drift. + */ +export const SIM_RESULT_PREFIX = '__SIM_RESULT__=' + +/** + * Extracts the `__SIM_RESULT__=` marker line from stdout and parses its JSON + * payload. Takes the LAST marker line: the wrapper prints its marker after all + * user output, so an earlier user-printed line with the same prefix (debug + * output, a grepped log) never shadows the real result. `parseFailed` means + * the last marker's payload was not valid JSON — `rawPayload` carries it so + * callers whose markers are user-authored (shell) can fall back to the plain + * string, while wrapper-backed callers treat it as transport corruption. + */ +function extractSimResult(stdout: string): { + result: unknown + cleanedStdout: string + parseFailed: boolean + rawPayload?: string +} { + const lines = stdout.split('\n') + let markerIndex = -1 + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith(SIM_RESULT_PREFIX)) { + markerIndex = i + break + } + } + if (markerIndex === -1) { + return { result: null, cleanedStdout: stdout, parseFailed: false } + } + const rawPayload = lines[markerIndex].slice(SIM_RESULT_PREFIX.length) + let result: unknown = null + let parseFailed = false + try { + result = JSON.parse(rawPayload) + } catch { + parseFailed = true + } + const filteredLines = lines.filter((l) => !l.startsWith(SIM_RESULT_PREFIX)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() + } + return { result, cleanedStdout: filteredLines.join('\n'), parseFailed, rawPayload } +} + +const SIM_RESULT_CORRUPTED_ERROR = + 'Sandbox result was corrupted in transport (the __SIM_RESULT__ line failed to parse). ' + + "Do not trust or persist this call's output. For large results, write the content to a " + + 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' + function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() const binaryExts = new Set([ @@ -196,8 +249,6 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise l.startsWith(prefix)) - let cleanedStdout = stdout - if (marker) { - const jsonPart = marker.slice(prefix.length) - try { - result = JSON.parse(jsonPart) - } catch { - result = jsonPart - } - const filteredLines = lines.filter((l) => !l.startsWith(prefix)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() + // Kernel stream entries are chunks, not lines — each already carries its own + // newlines, and one long line can arrive split across several entries. + // Concatenate each stream verbatim: joining chunks with '\n' injected a + // newline at every chunk boundary, which corrupted large single-line + // __SIM_RESULT__ payloads and silently truncated the persisted result. + // Distinct sources (final-expression text, stdout, stderr) still join with + // '\n' so the marker is found no matter which stream carried it. + const streamStdout = (execution.logs?.stdout ?? []).join('') + const streamStderr = (execution.logs?.stderr ?? []).join('') + const combinedOutput = [execution.text, streamStdout, streamStderr].filter(Boolean).join('\n') + + const extraction = extractSimResult(combinedOutput) + const cleanedStdout = extraction.cleanedStdout + + // The wrapper always emits valid single-line JSON, so a marker that fails + // to parse means the payload was mangled in transport — never persist it. + if (extraction.parseFailed) { + logger.error('E2B result marker failed to parse', { + sandboxId, + stdoutEntryCount: execution.logs?.stdout?.length ?? 0, + stdoutLength: streamStdout.length, + }) + return { + result: null, + stdout: cleanedStdout, + error: SIM_RESULT_CORRUPTED_ERROR, + sandboxId, } - cleanedStdout = filteredLines.join('\n') } + const result = extraction.result const images: string[] = [] if (execution.results?.length) { @@ -348,24 +399,13 @@ export async function executeShellInE2B( } } - let parsed: unknown = null - const prefix = '__SIM_RESULT__=' - const lines = stdout.split('\n') - const marker = lines.find((l) => l.startsWith(prefix)) - let cleanedStdout = stdout - if (marker) { - const jsonPart = marker.slice(prefix.length) - try { - parsed = JSON.parse(jsonPart) - } catch { - parsed = jsonPart - } - const filteredLines = lines.filter((l) => !l.startsWith(prefix)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() - } - cleanedStdout = filteredLines.join('\n') - } + // Shell scripts have no wrapper: any __SIM_RESULT__ line is user-authored + // (e.g. `echo "__SIM_RESULT__=$STATUS"`), so a non-JSON payload is a plain + // string result, not transport corruption. commands.run also accumulates + // output into one string, so the chunk-split corruption cannot occur here. + const extraction = extractSimResult(stdout) + const parsed = extraction.parseFailed ? extraction.rawPayload : extraction.result + const cleanedStdout = extraction.cleanedStdout const exportedFiles: Record = {} for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { diff --git a/apps/sim/lib/guardrails/pii-entities.test.ts b/apps/sim/lib/guardrails/pii-entities.test.ts new file mode 100644 index 00000000000..6c0d67debc2 --- /dev/null +++ b/apps/sim/lib/guardrails/pii-entities.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getEntityGroupsForLanguage, + NER_PII_ENTITIES, + normalizeRuleStages, + stripNerEntities, +} from '@/lib/guardrails/pii-entities' + +describe('NER_PII_ENTITIES', () => { + it('covers the spaCy-NER entities including ORGANIZATION', () => { + expect(new Set(NER_PII_ENTITIES)).toEqual( + new Set(['PERSON', 'LOCATION', 'NRP', 'DATE_TIME', 'ORGANIZATION']) + ) + }) +}) + +describe('stripNerEntities', () => { + it('drops NER entities and keeps regex/checksum ones (order preserved)', () => { + expect( + stripNerEntities([ + 'PERSON', + 'EMAIL_ADDRESS', + 'DATE_TIME', + 'US_SSN', + 'ORGANIZATION', + 'LOCATION', + 'PHONE_NUMBER', + ]) + ).toEqual(['EMAIL_ADDRESS', 'US_SSN', 'PHONE_NUMBER']) + }) + + it('returns an empty list when only NER was selected', () => { + expect(stripNerEntities(['PERSON', 'NRP'])).toEqual([]) + }) + + it('is a no-op for a regex-only list', () => { + expect(stripNerEntities(['EMAIL_ADDRESS', 'US_SSN'])).toEqual(['EMAIL_ADDRESS', 'US_SSN']) + }) +}) + +describe('getEntityGroupsForLanguage', () => { + const flatten = (groups: Array<{ entities: Array<{ value: string }> }>) => + groups.flatMap((g) => g.entities.map((e) => e.value)) + + it('includes NER entities by default', () => { + const values = flatten(getEntityGroupsForLanguage('en')) + expect(values).toContain('PERSON') + expect(values).toContain('EMAIL_ADDRESS') + }) + + it('excludes the spaCy-NER entities when regexOnly', () => { + const values = flatten(getEntityGroupsForLanguage('en', { regexOnly: true })) + for (const ner of ['PERSON', 'LOCATION', 'NRP', 'DATE_TIME']) { + expect(values).not.toContain(ner) + } + // Regex/checksum entities remain selectable. + expect(values).toContain('EMAIL_ADDRESS') + expect(values).toContain('US_SSN') + }) +}) + +describe('normalizeRuleStages', () => { + it('strips NER from a stored blockOutputs stage (input/logs keep it)', () => { + const stages = normalizeRuleStages({ + stages: { + input: { enabled: true, entityTypes: ['PERSON', 'EMAIL_ADDRESS'], language: 'en' }, + blockOutputs: { enabled: true, entityTypes: ['PERSON', 'EMAIL_ADDRESS'], language: 'en' }, + logs: { enabled: true, entityTypes: ['DATE_TIME'], language: 'en' }, + }, + }) + expect(stages.blockOutputs.entityTypes).toEqual(['EMAIL_ADDRESS']) + expect(stages.blockOutputs.enabled).toBe(true) + expect(stages.input.entityTypes).toEqual(['PERSON', 'EMAIL_ADDRESS']) + expect(stages.logs.entityTypes).toEqual(['DATE_TIME']) + }) + + it('disables blockOutputs when the NER strip empties it', () => { + const stages = normalizeRuleStages({ + stages: { + input: { enabled: false, entityTypes: [] }, + blockOutputs: { enabled: true, entityTypes: ['PERSON', 'LOCATION'] }, + logs: { enabled: false, entityTypes: [] }, + }, + }) + expect(stages.blockOutputs.entityTypes).toEqual([]) + expect(stages.blockOutputs.enabled).toBe(false) + }) +}) diff --git a/apps/sim/lib/guardrails/pii-entities.ts b/apps/sim/lib/guardrails/pii-entities.ts index 9fd57eee8a8..cb7f5504636 100644 --- a/apps/sim/lib/guardrails/pii-entities.ts +++ b/apps/sim/lib/guardrails/pii-entities.ts @@ -229,11 +229,43 @@ export function isEntitySupportedForLanguage( return PII_ENTITIES_BY_LANGUAGE[language].has(entity) } -/** {@link PII_ENTITY_GROUPS} filtered to entities the language recognizes (empty groups dropped). */ -export function getEntityGroupsForLanguage(language: PIILanguage) { +/** + * Entity types produced by the spaCy NER model (vs the regex/checksum pattern + * recognizers). The block-output redaction stage is restricted to the non-NER + * (regex) entities so it runs on the Presidio spaCy-free fast path without the + * per-leaf NER cost. Includes ORGANIZATION — which Presidio's spaCy recognizer + * emits but the user-facing catalog above does not list — so it too is stripped + * from block-output selections, keeping this in sync with the derived + * `NER_ENTITIES` in `apps/pii/server.py`. Typed as strings because ORGANIZATION + * isn't a catalog `PIIEntityType`. + */ +export const NER_PII_ENTITIES: ReadonlySet = new Set([ + 'PERSON', + 'LOCATION', + 'NRP', + 'DATE_TIME', + 'ORGANIZATION', +]) + +/** Drop the spaCy-NER entities ({@link NER_PII_ENTITIES}) from a selection. */ +export function stripNerEntities(entities: readonly string[]): string[] { + return entities.filter((e) => !NER_PII_ENTITIES.has(e)) +} + +/** + * {@link PII_ENTITY_GROUPS} filtered to entities the language recognizes (empty + * groups dropped). With `regexOnly`, the spaCy-NER entities are also excluded — + * used for the block-output stage. + */ +export function getEntityGroupsForLanguage(language: PIILanguage, opts?: { regexOnly?: boolean }) { + const regexOnly = opts?.regexOnly ?? false return PII_ENTITY_GROUPS.map((group) => ({ label: group.label, - entities: group.entities.filter((e) => isEntitySupportedForLanguage(e.value, language)), + entities: group.entities.filter( + (e) => + isEntitySupportedForLanguage(e.value, language) && + (!regexOnly || !NER_PII_ENTITIES.has(e.value)) + ), })).filter((group) => group.entities.length > 0) } @@ -319,9 +351,17 @@ export function normalizeRuleStages(rule: { }) if (rule.stages) { + // Block outputs are regex-only (no spaCy NER) — strip NER from any stored + // rule so hydrated drafts never carry it; a stage left empty becomes disabled. + const blockOutputs = sanitize(rule.stages.blockOutputs) + const blockOutputsEntities = stripNerEntities(blockOutputs.entityTypes) return { input: sanitize(rule.stages.input), - blockOutputs: sanitize(rule.stages.blockOutputs), + blockOutputs: { + ...blockOutputs, + entityTypes: blockOutputsEntities, + enabled: blockOutputs.enabled && blockOutputsEntities.length > 0, + }, logs: sanitize(rule.stages.logs), } } diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index bd6450d18c8..1ab1b49b330 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -34,6 +34,7 @@ import { ClayIcon, ClerkIcon, ClickHouseIcon, + ClickUpIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -180,6 +181,7 @@ import { ResendIcon, RevenueCatIcon, RipplingIcon, + RocketlaneIcon, RootlyIcon, S3Icon, SalesforceIcon, @@ -271,6 +273,7 @@ export const blockTypeToIconMap: Record = { clay: ClayIcon, clerk: ClerkIcon, clickhouse: ClickHouseIcon, + clickup: ClickUpIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, @@ -336,6 +339,7 @@ export const blockTypeToIconMap: Record = { google_vault: GoogleVaultIcon, grafana: GrafanaIcon, grain: GrainIcon, + grain_v2: GrainIcon, granola: GranolaIcon, greenhouse: GreenhouseIcon, greptile: GreptileIcon, @@ -418,6 +422,7 @@ export const blockTypeToIconMap: Record = { resend: ResendIcon, revenuecat: RevenueCatIcon, rippling: RipplingIcon, + rocketlane: RocketlaneIcon, rootly: RootlyIcon, s3: S3Icon, salesforce: SalesforceIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 4384eb6c34f..de2f74c9522 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-13", + "updatedAt": "2026-07-16", "integrations": [ { "type": "onepassword", @@ -3239,6 +3239,324 @@ "integrationType": "databases", "tags": ["data-warehouse", "data-analytics"] }, + { + "type": "clickup", + "slug": "clickup", + "name": "ClickUp", + "description": "Interact with ClickUp", + "longDescription": "Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. Can also trigger workflows on ClickUp events like task, list, folder, space, and goal changes.", + "bgColor": "#FFFFFF", + "iconName": "ClickUpIcon", + "docsUrl": "https://docs.sim.ai/integrations/clickup", + "operations": [ + { + "name": "Create Task", + "description": "Create a new task in a ClickUp list" + }, + { + "name": "Get Task", + "description": "Retrieve a task from ClickUp by ID" + }, + { + "name": "Update Task", + "description": "Update an existing task in ClickUp" + }, + { + "name": "Delete Task", + "description": "Delete a task from ClickUp" + }, + { + "name": "Get Tasks", + "description": "List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate)" + }, + { + "name": "Search Tasks", + "description": "Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate)" + }, + { + "name": "Create Comment", + "description": "Add a comment to a ClickUp task" + }, + { + "name": "Get Comments", + "description": "Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId)" + }, + { + "name": "Update Comment", + "description": "Update the content, assignee, or resolved state of a ClickUp task comment" + }, + { + "name": "Delete Comment", + "description": "Delete a comment from a ClickUp task" + }, + { + "name": "Upload Attachment", + "description": "Upload a file to a ClickUp task as an attachment" + }, + { + "name": "Add Tag to Task", + "description": "Add an existing space tag to a ClickUp task" + }, + { + "name": "Remove Tag from Task", + "description": "Remove a tag from a ClickUp task" + }, + { + "name": "Get Space Tags", + "description": "List the task tags available in a ClickUp space" + }, + { + "name": "Get Task Members", + "description": "List the workspace members who have explicit access to a ClickUp task" + }, + { + "name": "Get List Members", + "description": "List the workspace members who have explicit access to a ClickUp list" + }, + { + "name": "Get Custom Fields", + "description": "List the custom fields accessible in a ClickUp list" + }, + { + "name": "Set Custom Field Value", + "description": "Set the value of a custom field on a ClickUp task (the value shape depends on the field type)" + }, + { + "name": "Remove Custom Field Value", + "description": "Remove the value of a custom field from a ClickUp task (does not delete the field itself)" + }, + { + "name": "Create Checklist", + "description": "Add a new checklist to a ClickUp task" + }, + { + "name": "Update Checklist", + "description": "Rename or reorder a checklist on a ClickUp task" + }, + { + "name": "Delete Checklist", + "description": "Delete a checklist from a ClickUp task" + }, + { + "name": "Create Checklist Item", + "description": "Add an item to a checklist on a ClickUp task" + }, + { + "name": "Update Checklist Item", + "description": "Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it" + }, + { + "name": "Delete Checklist Item", + "description": "Delete an item from a checklist on a ClickUp task" + }, + { + "name": "Get Time Entries", + "description": "List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user)" + }, + { + "name": "Create Time Entry", + "description": "Create a manual time entry in a ClickUp workspace, optionally linked to a task" + }, + { + "name": "Update Time Entry", + "description": "Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state" + }, + { + "name": "Delete Time Entry", + "description": "Delete a time entry from a ClickUp workspace" + }, + { + "name": "Start Timer", + "description": "Start a timer for the authenticated user in a ClickUp workspace" + }, + { + "name": "Stop Timer", + "description": "Stop the authenticated user's currently running timer in a ClickUp workspace" + }, + { + "name": "Get Running Timer", + "description": "Get the currently running time entry in a ClickUp workspace (null when no timer is running)" + }, + { + "name": "Get Workspaces", + "description": "List the ClickUp workspaces (teams) available to the connected account" + }, + { + "name": "Get Spaces", + "description": "List the spaces in a ClickUp workspace" + }, + { + "name": "Get Folders", + "description": "List the folders in a ClickUp space" + }, + { + "name": "Get Lists", + "description": "List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead" + }, + { + "name": "Create Folder", + "description": "Create a new folder in a ClickUp space" + }, + { + "name": "Create List", + "description": "Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead" + } + ], + "operationCount": 38, + "triggers": [ + { + "id": "clickup_task_created", + "name": "ClickUp Task Created", + "description": "Trigger workflow when a task is created in ClickUp" + }, + { + "id": "clickup_task_updated", + "name": "ClickUp Task Updated", + "description": "Trigger workflow when a task is updated in ClickUp" + }, + { + "id": "clickup_task_deleted", + "name": "ClickUp Task Deleted", + "description": "Trigger workflow when a task is deleted in ClickUp" + }, + { + "id": "clickup_task_status_updated", + "name": "ClickUp Task Status Updated", + "description": "Trigger workflow when the status of a task changes in ClickUp" + }, + { + "id": "clickup_task_priority_updated", + "name": "ClickUp Task Priority Updated", + "description": "Trigger workflow when the priority of a task changes in ClickUp" + }, + { + "id": "clickup_task_assignee_updated", + "name": "ClickUp Task Assignee Updated", + "description": "Trigger workflow when the assignees of a task change in ClickUp" + }, + { + "id": "clickup_task_due_date_updated", + "name": "ClickUp Task Due Date Updated", + "description": "Trigger workflow when the due date of a task changes in ClickUp" + }, + { + "id": "clickup_task_tag_updated", + "name": "ClickUp Task Tag Updated", + "description": "Trigger workflow when the tags of a task change in ClickUp" + }, + { + "id": "clickup_task_moved", + "name": "ClickUp Task Moved", + "description": "Trigger workflow when a task is moved to a different list in ClickUp" + }, + { + "id": "clickup_task_comment_posted", + "name": "ClickUp Task Comment Posted", + "description": "Trigger workflow when a comment is posted on a task in ClickUp" + }, + { + "id": "clickup_task_comment_updated", + "name": "ClickUp Task Comment Updated", + "description": "Trigger workflow when a task comment is updated in ClickUp" + }, + { + "id": "clickup_task_time_estimate_updated", + "name": "ClickUp Task Time Estimate Updated", + "description": "Trigger workflow when the time estimate of a task changes in ClickUp" + }, + { + "id": "clickup_task_time_tracked_updated", + "name": "ClickUp Task Time Tracked Updated", + "description": "Trigger workflow when the tracked time of a task changes in ClickUp" + }, + { + "id": "clickup_list_created", + "name": "ClickUp List Created", + "description": "Trigger workflow when a list is created in ClickUp" + }, + { + "id": "clickup_list_updated", + "name": "ClickUp List Updated", + "description": "Trigger workflow when a list is updated in ClickUp" + }, + { + "id": "clickup_list_deleted", + "name": "ClickUp List Deleted", + "description": "Trigger workflow when a list is deleted in ClickUp" + }, + { + "id": "clickup_folder_created", + "name": "ClickUp Folder Created", + "description": "Trigger workflow when a folder is created in ClickUp" + }, + { + "id": "clickup_folder_updated", + "name": "ClickUp Folder Updated", + "description": "Trigger workflow when a folder is updated in ClickUp" + }, + { + "id": "clickup_folder_deleted", + "name": "ClickUp Folder Deleted", + "description": "Trigger workflow when a folder is deleted in ClickUp" + }, + { + "id": "clickup_space_created", + "name": "ClickUp Space Created", + "description": "Trigger workflow when a space is created in ClickUp" + }, + { + "id": "clickup_space_updated", + "name": "ClickUp Space Updated", + "description": "Trigger workflow when a space is updated in ClickUp" + }, + { + "id": "clickup_space_deleted", + "name": "ClickUp Space Deleted", + "description": "Trigger workflow when a space is deleted in ClickUp" + }, + { + "id": "clickup_goal_created", + "name": "ClickUp Goal Created", + "description": "Trigger workflow when a goal is created in ClickUp" + }, + { + "id": "clickup_goal_updated", + "name": "ClickUp Goal Updated", + "description": "Trigger workflow when a goal is updated in ClickUp" + }, + { + "id": "clickup_goal_deleted", + "name": "ClickUp Goal Deleted", + "description": "Trigger workflow when a goal is deleted in ClickUp" + }, + { + "id": "clickup_key_result_created", + "name": "ClickUp Key Result Created", + "description": "Trigger workflow when a key result is created in ClickUp" + }, + { + "id": "clickup_key_result_updated", + "name": "ClickUp Key Result Updated", + "description": "Trigger workflow when a key result is updated in ClickUp" + }, + { + "id": "clickup_key_result_deleted", + "name": "ClickUp Key Result Deleted", + "description": "Trigger workflow when a key result is deleted in ClickUp" + }, + { + "id": "clickup_webhook", + "name": "ClickUp Webhook", + "description": "Trigger workflow on any ClickUp event (subscribes to all events)" + } + ], + "triggerCount": 29, + "authType": "oauth", + "oauthServiceId": "clickup", + "category": "tools", + "integrationType": "productivity", + "tags": ["project-management", "ticketing", "automation"] + }, { "type": "cloudflare", "slug": "cloudflare", @@ -8127,7 +8445,7 @@ "tags": ["monitoring", "data-analytics"] }, { - "type": "grain", + "type": "grain_v2", "slug": "grain", "name": "Grain", "description": "Access meeting recordings, transcripts, and AI summaries", @@ -8148,10 +8466,6 @@ "name": "Get Transcript", "description": "Get the full transcript of a recording" }, - { - "name": "List Views", - "description": "List available Grain views for webhook subscriptions" - }, { "name": "List Teams", "description": "List all teams in the workspace" @@ -8162,61 +8476,76 @@ }, { "name": "Create Webhook", - "description": "Create a webhook to receive recording events" + "description": "Create a webhook for a specific Grain event type (v2 API)" }, { "name": "List Webhooks", - "description": "List all webhooks for the account" + "description": "List webhooks for the account (v2 API)" }, { "name": "Delete Webhook", - "description": "Delete a webhook by ID" + "description": "Delete a webhook by ID (v2 API)" } ], - "operationCount": 9, + "operationCount": 8, "triggers": [ { - "id": "grain_item_added", - "name": "Grain Item Added", - "description": "Trigger when a new item is added to a Grain view (recording, highlight, or story)" + "id": "grain_recording_added_v2", + "name": "Grain Recording Added", + "description": "Trigger when a new recording is added in Grain" }, { - "id": "grain_item_updated", - "name": "Grain Item Updated", - "description": "Trigger when an item is updated in a Grain view (recording, highlight, or story)" + "id": "grain_recording_updated_v2", + "name": "Grain Recording Updated", + "description": "Trigger when a recording is updated in Grain" }, { - "id": "grain_webhook", - "name": "Grain All Events", - "description": "Trigger on all actions (added, updated, removed) in a Grain view" + "id": "grain_recording_deleted_v2", + "name": "Grain Recording Deleted", + "description": "Trigger when a recording is deleted in Grain" }, { - "id": "grain_recording_created", - "name": "Grain Recording Created", - "description": "Trigger workflow when a new recording is added in Grain" + "id": "grain_highlight_added_v2", + "name": "Grain Highlight Added", + "description": "Trigger when a new highlight/clip is created in Grain" }, { - "id": "grain_recording_updated", - "name": "Grain Recording Updated", - "description": "Trigger workflow when a recording is updated in Grain" + "id": "grain_highlight_updated_v2", + "name": "Grain Highlight Updated", + "description": "Trigger when a highlight/clip is updated in Grain" }, { - "id": "grain_highlight_created", - "name": "Grain Highlight Created", - "description": "Trigger workflow when a new highlight/clip is created in Grain" + "id": "grain_highlight_deleted_v2", + "name": "Grain Highlight Deleted", + "description": "Trigger when a highlight/clip is deleted in Grain" }, { - "id": "grain_highlight_updated", - "name": "Grain Highlight Updated", - "description": "Trigger workflow when a highlight/clip is updated in Grain" + "id": "grain_story_added_v2", + "name": "Grain Story Added", + "description": "Trigger when a new story is created in Grain" + }, + { + "id": "grain_story_updated_v2", + "name": "Grain Story Updated", + "description": "Trigger when a story is updated in Grain" }, { - "id": "grain_story_created", - "name": "Grain Story Created", - "description": "Trigger workflow when a new story is created in Grain" + "id": "grain_story_deleted_v2", + "name": "Grain Story Deleted", + "description": "Trigger when a story is deleted in Grain" + }, + { + "id": "grain_upload_status_v2", + "name": "Grain Upload Status", + "description": "Trigger on progress updates for recordings uploaded to Grain" + }, + { + "id": "grain_all_events_v2", + "name": "Grain All Events", + "description": "Trigger on every Grain event (recordings, highlights, stories, uploads)" } ], - "triggerCount": 8, + "triggerCount": 11, "authType": "api-key", "category": "tools", "integrationType": "productivity", @@ -14906,6 +15235,281 @@ "integrationType": "hr", "tags": ["hiring"] }, + { + "type": "rocketlane", + "slug": "rocketlane", + "name": "Rocketlane", + "description": "Manage client onboarding projects, tasks, time tracking, and invoices", + "longDescription": "Integrate Rocketlane into your workflow. Rocketlane is a professional-services automation platform for client onboarding and project delivery. Create and manage projects, tasks, phases, custom fields, time entries, time-offs, spaces, documents, resource allocations, and invoices.", + "bgColor": "#000000", + "iconName": "RocketlaneIcon", + "docsUrl": "https://docs.sim.ai/integrations/rocketlane", + "operations": [ + { + "name": "Create Project", + "description": "Create a new Rocketlane project with a customer, owner, dates, team members, templates, financials, and custom fields" + }, + { + "name": "Get Project", + "description": "Retrieve a Rocketlane project by its unique identifier" + }, + { + "name": "List Projects", + "description": "List Rocketlane projects with optional filters, sorting, and token-based pagination" + }, + { + "name": "Update Project", + "description": "Update a Rocketlane project by ID, including name, dates, visibility, owner, status, and custom fields" + }, + { + "name": "Archive Project", + "description": "Archive a Rocketlane project by ID, making it dormant while preserving its details and history" + }, + { + "name": "Delete Project", + "description": "Permanently delete a Rocketlane project by ID (irreversible; only Admins, Super Users, and Project Owners can delete)" + }, + { + "name": "Add Project Members", + "description": "Add team members and customer stakeholders to a Rocketlane project" + }, + { + "name": "Remove Project Members", + "description": "Remove team members from a Rocketlane project" + }, + { + "name": "Import Template", + "description": "Import a project template into an existing Rocketlane project" + }, + { + "name": "List Placeholders", + "description": "List the placeholders of a Rocketlane project" + }, + { + "name": "Assign Placeholder", + "description": "Assign a placeholder in a Rocketlane project to a user" + }, + { + "name": "Unassign Placeholder", + "description": "Unassign a placeholder from its user in a Rocketlane project" + }, + { + "name": "Create Task", + "description": "Create a new task in a Rocketlane project" + }, + { + "name": "Get Task", + "description": "Retrieve detailed information about a Rocketlane task by its unique identifier" + }, + { + "name": "List Tasks", + "description": "Retrieve all Rocketlane tasks with optional filters, sorting, and pagination" + }, + { + "name": "Update Task", + "description": "Update the properties of an existing Rocketlane task by its unique identifier" + }, + { + "name": "Delete Task", + "description": "Permanently delete a Rocketlane task by its unique identifier" + }, + { + "name": "Move Task to Phase", + "description": "Move a Rocketlane task to a given phase, associating the task with that phase" + }, + { + "name": "Add Task Assignees", + "description": "Add members as assignees to a Rocketlane task" + }, + { + "name": "Remove Task Assignees", + "description": "Remove assignees from a Rocketlane task" + }, + { + "name": "Add Task Followers", + "description": "Add members as followers to a Rocketlane task" + }, + { + "name": "Remove Task Followers", + "description": "Remove followers from a Rocketlane task" + }, + { + "name": "Add Task Dependencies", + "description": "Add finish-to-start dependencies between a Rocketlane task and other tasks" + }, + { + "name": "Remove Task Dependencies", + "description": "Remove dependencies from a Rocketlane task" + }, + { + "name": "Create Phase", + "description": "Create a new phase in a Rocketlane project" + }, + { + "name": "Get Phase", + "description": "Retrieve a single Rocketlane phase by ID" + }, + { + "name": "List Phases", + "description": "List phases of a Rocketlane project, with optional filters, sorting, and pagination" + }, + { + "name": "Update Phase", + "description": "Update the name, dates, status, or privacy of an existing Rocketlane phase" + }, + { + "name": "Delete Phase", + "description": "Permanently delete a Rocketlane phase by ID" + }, + { + "name": "Create Field", + "description": "Create a custom field in your Rocketlane account, with options for SINGLE_CHOICE and MULTIPLE_CHOICE field types" + }, + { + "name": "Get Field", + "description": "Retrieve a single Rocketlane field by ID" + }, + { + "name": "List Fields", + "description": "List fields in your Rocketlane account, with optional filters, sorting, and pagination" + }, + { + "name": "Update Field", + "description": "Update the label, description, enabled state, or privacy of an existing Rocketlane field" + }, + { + "name": "Delete Field", + "description": "Permanently delete a Rocketlane field by ID" + }, + { + "name": "Add Field Option", + "description": "Add a new option to an existing SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field" + }, + { + "name": "Update Field Option", + "description": "Update the label or color of an existing option on a SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field" + }, + { + "name": "Create Time Entry", + "description": "Create a time entry in Rocketlane against an adhoc activity, task, project phase, or project" + }, + { + "name": "Get Time Entry", + "description": "Get a single Rocketlane time entry by ID" + }, + { + "name": "List Time Entries", + "description": "List Rocketlane time entries with optional filters, sorting, and cursor-based pagination" + }, + { + "name": "Search Time Entries", + "description": "Search Rocketlane time entries with filters, sorting, and pagination (deprecated by Rocketlane in favor of listing time entries with filters)" + }, + { + "name": "Update Time Entry", + "description": "Update a Rocketlane time entry by ID. The activityName, notes, billable, and minutes properties can be updated" + }, + { + "name": "Delete Time Entry", + "description": "Permanently delete a Rocketlane time entry by ID" + }, + { + "name": "List Time Entry Categories", + "description": "List the time entry categories configured in Rocketlane" + }, + { + "name": "Create Time-Off", + "description": "Create a time-off for a team member in Rocketlane. Holidays and weekends within the date range are automatically excluded from the duration." + }, + { + "name": "Get Time-Off", + "description": "Retrieve a Rocketlane time-off by its ID" + }, + { + "name": "List Time-Offs", + "description": "List time-offs in Rocketlane with optional date, type, and user filters, sorting, and pagination" + }, + { + "name": "Delete Time-Off", + "description": "Permanently delete a Rocketlane time-off by its ID" + }, + { + "name": "Get User", + "description": "Retrieve a Rocketlane user by their ID" + }, + { + "name": "List Users", + "description": "List users in your Rocketlane account, with optional filters, sorting, and pagination" + }, + { + "name": "Create Space", + "description": "Create a new space in a Rocketlane project" + }, + { + "name": "Get Space", + "description": "Retrieve a Rocketlane space by its ID" + }, + { + "name": "List Spaces", + "description": "List spaces in a Rocketlane project, with optional filters, sorting, and pagination" + }, + { + "name": "Update Space", + "description": "Update a Rocketlane space by its ID" + }, + { + "name": "Delete Space", + "description": "Permanently delete a Rocketlane space by its ID" + }, + { + "name": "Create Space Document", + "description": "Create a new space document in a Rocketlane space" + }, + { + "name": "Get Space Document", + "description": "Retrieve a Rocketlane space document by its ID" + }, + { + "name": "List Space Documents", + "description": "List space documents in a Rocketlane project, with optional filters, sorting, and pagination" + }, + { + "name": "Update Space Document", + "description": "Update a Rocketlane space document by its ID" + }, + { + "name": "Delete Space Document", + "description": "Permanently delete a Rocketlane space document by its ID" + }, + { + "name": "List Resource Allocations", + "description": "List resource allocations in Rocketlane within a date range, with optional member, project, and placeholder filters, sorting, and pagination" + }, + { + "name": "Get Invoice", + "description": "Retrieve a Rocketlane invoice by its ID" + }, + { + "name": "List Invoices", + "description": "Search invoices in Rocketlane with date, amount, company, invoice-number, and status filters, sorting, and pagination" + }, + { + "name": "Get Invoice Line Items", + "description": "List line items of a Rocketlane invoice" + }, + { + "name": "Get Invoice Payments", + "description": "List payments recorded against a Rocketlane invoice" + } + ], + "operationCount": 64, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "productivity", + "tags": ["project-management", "automation"] + }, { "type": "rootly", "slug": "rootly", diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 34712123df8..4cba5ce809e 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import integrationsJson from '@/lib/integrations/integrations.json' import type { Integration } from '@/lib/integrations/types' @@ -40,7 +41,8 @@ function asServiceAccountProviderId( value === 'google-service-account' || value === 'atlassian-service-account' || value === SLACK_CUSTOM_BOT_PROVIDER_ID || - isTokenServiceAccountProviderId(value) + isTokenServiceAccountProviderId(value) || + isClientCredentialAccountProviderId(value) ) { return value } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 0d7b113c825..6886590acbb 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -23,7 +23,6 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' -import { buildUserSkillTool } from '@/lib/mothership/skills' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -215,7 +214,6 @@ export async function executeInboxTask(taskId: string): Promise { attachmentResult, workspaceContext, integrationTools, - userSkillTool, userPermission, billingAttribution, entitlements, @@ -223,7 +221,6 @@ export async function executeInboxTask(taskId: string): Promise { fetchAttachments(), generateWorkspaceContext(ws.id, userId), buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - buildUserSkillTool(ws.id), getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), computeWorkspaceEntitlements(ws.id, userId), @@ -247,7 +244,6 @@ export async function executeInboxTask(taskId: string): Promise { workspaceContext, ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), ...(entitlements.length > 0 ? { entitlements } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), diff --git a/apps/sim/lib/mothership/skills.test.ts b/apps/sim/lib/mothership/skills.test.ts deleted file mode 100644 index 67f66e146d2..00000000000 --- a/apps/sim/lib/mothership/skills.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { whereMock } = vi.hoisted(() => ({ whereMock: vi.fn() })) - -vi.mock('@sim/db', () => ({ - db: { select: () => ({ from: () => ({ where: whereMock }) }) }, - skill: { workspaceId: 'workspaceId', name: 'name', description: 'description' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), -})) -vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) })) - -import { buildUserSkillTool, LOAD_USER_SKILL_TOOL_NAME } from './skills' - -describe('buildUserSkillTool', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns null without a workspace id', async () => { - expect(await buildUserSkillTool('')).toBeNull() - expect(whereMock).not.toHaveBeenCalled() - }) - - it('returns null when the workspace has no user skills', async () => { - whereMock.mockResolvedValue([]) - expect(await buildUserSkillTool('ws-1')).toBeNull() - }) - - it('builds one load_user_skill tool with an enum of workspace skill names', async () => { - whereMock.mockResolvedValue([ - { name: 'posthog-playbook', description: 'PostHog steps' }, - { name: 'brand-voice', description: 'Tone rules' }, - ]) - - const tool = await buildUserSkillTool('ws-1') - - expect(tool?.name).toBe(LOAD_USER_SKILL_TOOL_NAME) - // Must NOT be executeLocally — it dispatches with executor "sim", not the browser client. - expect(tool?.executeLocally).toBeUndefined() - expect(tool?.params).toMatchObject({ mothershipToolKind: 'skill' }) - expect(tool?.input_schema).toMatchObject({ - type: 'object', - properties: { skill_name: { enum: ['posthog-playbook', 'brand-voice'] } }, - required: ['skill_name'], - }) - expect(tool?.description).toContain('posthog-playbook') - expect(tool?.description).toContain('brand-voice') - }) - - it('returns null when the skill query fails', async () => { - whereMock.mockRejectedValue(new Error('db down')) - expect(await buildUserSkillTool('ws-1')).toBeNull() - }) -}) diff --git a/apps/sim/lib/mothership/skills.ts b/apps/sim/lib/mothership/skills.ts deleted file mode 100644 index cdb2b42ef95..00000000000 --- a/apps/sim/lib/mothership/skills.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { db, skill } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import type { ToolSchema } from '@/lib/copilot/chat/payload' - -const logger = createLogger('MothershipUserSkills') - -export const LOAD_USER_SKILL_TOOL_NAME = 'load_user_skill' - -/** - * Build the single load_user_skill tool that exposes all workspace - * user-created skills to the mothership and its subagents. - * - * User skills live in the `skill` table (builtins are code-only and are treated - * as defaults, so they are excluded here). The tool is a non-deferred, - * sim-executed request-local tool: when the model calls it, Go forwards the - * call and sim resolves the content via resolveSkillContent (see tools/index.ts). - * It is available to all users — there is no super-admin gate, unlike the curated - * mothership_settings tools. - * - * Embedded copilot/internal skills are not handled here: those are autoloaded - * into each agent's system prompt on the Go side and never sent as loadable. - */ -export async function buildUserSkillTool(workspaceId: string): Promise { - if (!workspaceId) return null - - let rows: { name: string; description: string }[] - try { - rows = await db - .select({ name: skill.name, description: skill.description }) - .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - } catch (error) { - logger.error('Failed to load workspace skills for load_user_skill tool', { error, workspaceId }) - return null - } - - if (rows.length === 0) return null - - const skillNames = rows.map((r) => r.name) - const catalog = rows.map((r) => `- ${r.name}: ${r.description}`).join('\n') - - return { - name: LOAD_USER_SKILL_TOOL_NAME, - description: `Load a user-created skill's full instructions. You MUST call this before following a skill: the list below only tells you which skills exist and when each applies — it is NOT the instructions. To use a skill, call load_user_skill with its exact name and follow the content it returns; never act on a skill's name or description alone. Available skills:\n${catalog}`, - input_schema: { - type: 'object', - properties: { - skill_name: { - type: 'string', - description: 'Exact name of the user skill to load.', - enum: skillNames, - }, - }, - required: ['skill_name'], - additionalProperties: false, - }, - // Do NOT set executeLocally: skill content is resolved on the sim backend - // (DB), so Go must dispatch this with executor "sim". executeLocally maps to - // ClientExecutable, which routes the call to the browser client (no handler) - // and the load hangs. mothershipToolKind 'skill' is enough for sim routing. - params: { - mothershipToolKind: 'skill', - mothershipToolName: LOAD_USER_SKILL_TOOL_NAME, - }, - } -} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index d1a0bb00dbe..c2cfad05096 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -8,6 +8,7 @@ import { AzureIcon, BoxCompanyIcon, CalComIcon, + ClickUpIcon, ConfluenceIcon, DocuSignIcon, DropboxIcon, @@ -626,6 +627,22 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'notion', }, + clickup: { + name: 'ClickUp', + icon: ClickUpIcon, + services: { + clickup: { + name: 'ClickUp', + description: 'Manage tasks, lists, and comments in ClickUp.', + providerId: 'clickup', + serviceAccountProviderId: 'clickup-service-account', + icon: ClickUpIcon, + baseProviderIcon: ClickUpIcon, + scopes: [], + }, + }, + defaultService: 'clickup', + }, linear: { name: 'Linear', icon: LinearIcon, @@ -677,6 +694,7 @@ export const OAUTH_PROVIDERS: Record = { icon: BoxCompanyIcon, baseProviderIcon: BoxCompanyIcon, scopes: ['root_readwrite', 'sign_requests.readwrite'], + serviceAccountProviderId: 'box-service-account', }, }, defaultService: 'box', @@ -930,6 +948,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Pipedrive', description: 'Manage deals, contacts, and sales pipeline in Pipedrive CRM.', providerId: 'pipedrive', + serviceAccountProviderId: 'pipedrive-service-account', icon: PipedriveIcon, baseProviderIcon: PipedriveIcon, scopes: [ @@ -1026,6 +1045,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Salesforce', description: 'Access and manage your Salesforce CRM data.', providerId: 'salesforce', + serviceAccountProviderId: 'salesforce-service-account', icon: SalesforceIcon, baseProviderIcon: SalesforceIcon, scopes: ['api', 'refresh_token', 'openid'], @@ -1056,6 +1076,7 @@ export const OAUTH_PROVIDERS: Record = { 'cloud_recording:read:list_recording_files', 'cloud_recording:delete:recording_file', ], + serviceAccountProviderId: 'zoom-service-account', }, }, defaultService: 'zoom', @@ -1270,6 +1291,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'clickup': { + const { clientId, clientSecret } = getCredentials( + env.CLICKUP_CLIENT_ID, + env.CLICKUP_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://api.clickup.com/api/v2/oauth/token', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: false, + } + } case 'linear': { const { clientId, clientSecret } = getCredentials( env.LINEAR_CLIENT_ID, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 96b7f1a06eb..40c24fe8547 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -65,6 +65,7 @@ export type OAuthProvider = | 'outlook' | 'onedrive' | 'sharepoint' + | 'clickup' | 'linear' | 'slack' | 'reddit' @@ -117,6 +118,7 @@ export type OAuthService = | 'microsoft-planner' | 'sharepoint' | 'outlook' + | 'clickup' | 'linear' | 'slack' | 'reddit' diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 9511eb5fd32..dcd47d895e2 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -695,6 +695,20 @@ export interface PostHogEventMap { locked: boolean } + /** A workflow's fork-sync exclusion ("Exclude from sync") was toggled on or off. */ + workflow_fork_sync_exclusion_toggled: { + workflow_id: string + workspace_id?: string + fork_sync_excluded: boolean + } + + /** A batch of workflows was marked or unmarked "Exclude from sync" from the Forks settings. */ + fork_excluded_workflows_updated: { + workspace_id: string + workflow_count: number + fork_sync_excluded: boolean + } + workflow_schedule_created: { workflow_id: string workspace_id: string diff --git a/apps/sim/lib/table/dispatch-concurrency.test.ts b/apps/sim/lib/table/dispatch-concurrency.test.ts new file mode 100644 index 00000000000..2581b6e987b --- /dev/null +++ b/apps/sim/lib/table/dispatch-concurrency.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnv, mockFlags } = vi.hoisted(() => ({ + mockEnv: {} as Record, + mockFlags: { isBillingEnabled: true }, +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: mockEnv, + envNumber: ( + value: number | string | undefined | null, + fallback: number, + options: { min?: number; integer?: boolean } = {} + ) => { + const parsed = Number(value) + const min = options.min ?? 0 + return Number.isFinite(parsed) && + parsed >= min && + (!options.integer || Number.isInteger(parsed)) + ? parsed + : fallback + }, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + get isBillingEnabled() { + return mockFlags.isBillingEnabled + }, +})) + +import { + getMaxTableDispatchConcurrency, + getTableDispatchConcurrency, +} from '@/lib/table/dispatch-concurrency' + +describe('getTableDispatchConcurrency', () => { + beforeEach(() => { + for (const key of Object.keys(mockEnv)) delete mockEnv[key] + mockFlags.isBillingEnabled = true + }) + + it('resolves free vs paid defaults', () => { + expect(getTableDispatchConcurrency(null)).toBe(20) + expect(getTableDispatchConcurrency('free')).toBe(20) + expect(getTableDispatchConcurrency('pro_6000')).toBe(50) + expect(getTableDispatchConcurrency('team_25000')).toBe(50) + expect(getTableDispatchConcurrency('enterprise')).toBe(50) + }) + + it('applies env overrides', () => { + mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '5' + mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '200' + + expect(getTableDispatchConcurrency('free')).toBe(5) + expect(getTableDispatchConcurrency('pro_6000')).toBe(200) + expect(getTableDispatchConcurrency('enterprise')).toBe(200) + }) + + it('uses the paid value when billing is disabled', () => { + mockFlags.isBillingEnabled = false + expect(getTableDispatchConcurrency(null)).toBe(50) + + mockEnv.TABLE_DISPATCH_CONCURRENCY_PAID = '120' + expect(getTableDispatchConcurrency(null)).toBe(120) + }) +}) + +describe('getMaxTableDispatchConcurrency', () => { + beforeEach(() => { + for (const key of Object.keys(mockEnv)) delete mockEnv[key] + }) + + it('returns the highest configured value', () => { + expect(getMaxTableDispatchConcurrency()).toBe(50) + + mockEnv.TABLE_DISPATCH_CONCURRENCY_FREE = '80' + expect(getMaxTableDispatchConcurrency()).toBe(80) + }) +}) diff --git a/apps/sim/lib/table/dispatch-concurrency.ts b/apps/sim/lib/table/dispatch-concurrency.ts new file mode 100644 index 00000000000..dd73b08c4a7 --- /dev/null +++ b/apps/sim/lib/table/dispatch-concurrency.ts @@ -0,0 +1,72 @@ +import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' +import { env, envNumber } from '@/lib/core/config/env' +import { isBillingEnabled } from '@/lib/core/config/env-flags' + +/** + * Default table dispatch concurrency — how many rows one table run executes + * in parallel (the dispatcher window size). Free vs paid (Pro, Max, + * Enterprise); overridable via `TABLE_DISPATCH_CONCURRENCY_{FREE,PAID}`. + */ +export const DEFAULT_TABLE_DISPATCH_CONCURRENCY = { + free: 20, + paid: 50, +} as const + +/** + * Resolves dispatch concurrency limits, applying env overrides on top of the + * defaults. + */ +export function getTableDispatchConcurrencyLimits(): { free: number; paid: number } { + return { + free: envNumber(env.TABLE_DISPATCH_CONCURRENCY_FREE, DEFAULT_TABLE_DISPATCH_CONCURRENCY.free, { + min: 1, + integer: true, + }), + paid: envNumber(env.TABLE_DISPATCH_CONCURRENCY_PAID, DEFAULT_TABLE_DISPATCH_CONCURRENCY.paid, { + min: 1, + integer: true, + }), + } +} + +/** + * Dispatch concurrency for one payer plan. Billing-disabled deployments get + * the paid value. + */ +export function getTableDispatchConcurrency(plan: string | null | undefined): number { + const limits = getTableDispatchConcurrencyLimits() + if (!isBillingEnabled) return limits.paid + return getPlanTypeForLimits(plan) === 'free' ? limits.free : limits.paid +} + +/** + * Highest configured dispatch concurrency. The `workflow-group-cell` + * trigger.dev queue cap derives from this so the server-side per-table + * ceiling never throttles below a plan's window. + */ +export function getMaxTableDispatchConcurrency(): number { + const limits = getTableDispatchConcurrencyLimits() + return Math.max(limits.free, limits.paid) +} + +/** + * Resolves the workspace payer's plan and returns its dispatch concurrency. + * Uses the same billing attribution the cells are billed under, so the window + * follows whoever pays for the run. + */ +export async function resolveTableDispatchConcurrency(input: { + workspaceId: string + actorUserId?: string | null +}): Promise { + if (!isBillingEnabled) return getTableDispatchConcurrencyLimits().paid + const { resolveBillingAttribution, resolveSystemBillingAttribution } = await import( + '@/lib/billing/core/billing-attribution' + ) + const attribution = input.actorUserId + ? await resolveBillingAttribution({ + actorUserId: input.actorUserId, + workspaceId: input.workspaceId, + }) + : await resolveSystemBillingAttribution(input.workspaceId) + return getTableDispatchConcurrency(attribution.payerSubscription?.plan) +} diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 44de520a68d..2206b03c34a 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -40,11 +40,6 @@ import { const logger = createLogger('TableRunDispatcher') -/** Window size matches the cell-execution concurrency cap so one window - * saturates the pool before the next is loaded — yields a row-major - * scan-line crawl (rows 1-20 finish before 21-40 start). */ -const WINDOW_SIZE = TABLE_CONCURRENCY_LIMIT - const ACTIVE_DISPATCH_STATUSES = ['pending', 'dispatching'] as const export type DispatchStatus = 'pending' | 'dispatching' | 'complete' | 'cancelled' @@ -323,14 +318,23 @@ export async function readDispatch(dispatchId: string): Promise { - while ((await dispatcherStep(dispatchId)) === 'continue') {} + * runtimes use identical loop semantics + error logging. `concurrency` is the + * invoker's plan-resolved window size (see `resolveTableDispatchConcurrency`), + * threaded via the task payload; absent on payloads from before the field + * existed → legacy cap. */ +export async function runDispatcherToCompletion( + dispatchId: string, + concurrency?: number +): Promise { + while ((await dispatcherStep(dispatchId, concurrency)) === 'continue') {} } /** Run one window of the dispatcher state machine. Caller re-invokes (via the * trigger.dev task wrapper) until the returned status is `'done'`. */ -export async function dispatcherStep(dispatchId: string): Promise { +export async function dispatcherStep( + dispatchId: string, + concurrency?: number +): Promise { const dispatch = await readDispatch(dispatchId) if (!dispatch) { logger.warn(`[${dispatchId}] dispatch row missing — aborting`) @@ -380,6 +384,11 @@ export async function dispatcherStep(dispatchId: string): Promise> { if (pendingRuns.length === 0) return [] @@ -292,7 +294,7 @@ export async function buildEnqueueItems( }, }, concurrencyKey: runOpts.tableId, - concurrencyLimit: TABLE_CONCURRENCY_LIMIT, + concurrencyLimit, tags: cellTagsFor(runOpts), ...(runner ? { runner } : {}), cancelKey: cellCancelKey(runOpts.tableId, runOpts.rowId, runOpts.groupId), @@ -391,7 +393,9 @@ export type QueuedWorkflowGroupCellPayload = Omit< billingAttribution: BillingAttributionSnapshot } -/** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 20`. */ +/** Legacy per-table concurrency cap. The live cap is per-plan (see + * `getTableDispatchConcurrency`); this remains the fallback for dispatch rows + * that predate the `concurrency` column and for non-dispatch cell enqueues. */ export const TABLE_CONCURRENCY_LIMIT = 20 /** @@ -745,6 +749,13 @@ export async function runWorkflowColumn(opts: { runDispatcherToCompletion, } = await import('./dispatcher') + // Per-window parallelism follows the invoker's plan, resolved once here and + // threaded through the dispatcher invocation (task payload / loop arg). + const concurrency = await resolveTableDispatchConcurrency({ + workspaceId, + actorUserId: triggeredByUserId, + }) + // Always insert a `table_run_dispatches` row, and insert it FIRST — before // the prior-run cancel and the bulk clear below, which can take seconds on // a large table. The client shows its Stop control optimistically from the @@ -871,14 +882,14 @@ export async function runWorkflowColumn(opts: { ]) await tasks.trigger( 'table-run-dispatcher', - { dispatchId }, + { dispatchId, concurrency }, { concurrencyKey: dispatchId, region: await resolveTriggerRegion() } ) } else { // Local / no-trigger.dev: drive the same loop in-process, fire-and-forget // so the HTTP request returns instantly (mirrors the trigger.dev path's // async fan-out). - void runDispatcherToCompletion(dispatchId).catch((err) => + void runDispatcherToCompletion(dispatchId, concurrency).catch((err) => logger.error(`[${requestId}] dispatcher loop failed`, { dispatchId, error: toError(err).message, diff --git a/apps/sim/lib/tokenization/constants.ts b/apps/sim/lib/tokenization/constants.ts index 7012e0dfe23..2fb27ec88a5 100644 --- a/apps/sim/lib/tokenization/constants.ts +++ b/apps/sim/lib/tokenization/constants.ts @@ -76,6 +76,11 @@ export const TOKENIZATION_CONFIG = { confidence: 'medium', supportedMethods: ['heuristic', 'fallback'], }, + kimi: { + avgCharsPerToken: 4, + confidence: 'medium', + supportedMethods: ['heuristic', 'fallback'], + }, ollama: { avgCharsPerToken: 4, confidence: 'low', diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts index d833d19c9f8..10cb9a2eff7 100644 --- a/apps/sim/lib/uploads/config.ts +++ b/apps/sim/lib/uploads/config.ts @@ -10,9 +10,11 @@ export const hasBlobConfig = !!( env.AZURE_STORAGE_CONTAINER_NAME && ((env.AZURE_ACCOUNT_NAME && env.AZURE_ACCOUNT_KEY) || env.AZURE_CONNECTION_STRING) ) +const hasGcsConfig = !!env.GCS_BUCKET_NAME export const USE_BLOB_STORAGE = hasBlobConfig export const USE_S3_STORAGE = hasS3Config && !USE_BLOB_STORAGE +export const USE_GCS_STORAGE = hasGcsConfig && !USE_BLOB_STORAGE && !USE_S3_STORAGE export const S3_CONFIG = { bucket: env.S3_BUCKET_NAME || '', @@ -35,6 +37,38 @@ export const BLOB_CONFIG = { containerName: env.AZURE_STORAGE_CONTAINER_NAME || '', } +export const GCS_CONFIG = { + bucket: env.GCS_BUCKET_NAME || '', +} + +export const GCS_KB_CONFIG = { + bucket: env.GCS_KB_BUCKET_NAME || '', +} + +export const GCS_EXECUTION_FILES_CONFIG = { + bucket: env.GCS_EXECUTION_FILES_BUCKET_NAME || '', +} + +export const GCS_CHAT_CONFIG = { + bucket: env.GCS_CHAT_BUCKET_NAME || '', +} + +export const GCS_COPILOT_CONFIG = { + bucket: env.GCS_COPILOT_BUCKET_NAME || '', +} + +export const GCS_PROFILE_PICTURES_CONFIG = { + bucket: env.GCS_PROFILE_PICTURES_BUCKET_NAME || '', +} + +export const GCS_OG_IMAGES_CONFIG = { + bucket: env.GCS_OG_IMAGES_BUCKET_NAME || '', +} + +export const GCS_WORKSPACE_LOGOS_CONFIG = { + bucket: env.GCS_WORKSPACE_LOGOS_BUCKET_NAME || '', +} + export const S3_KB_CONFIG = { bucket: env.S3_KB_BUCKET_NAME || '', region: env.AWS_REGION || '', @@ -122,17 +156,28 @@ export const BLOB_WORKSPACE_LOGOS_CONFIG = { /** * Get the current storage provider as a human-readable string */ -export function getStorageProvider(): 'Azure Blob' | 'S3' | 'Local' { +export function getStorageProvider(): 'Azure Blob' | 'S3' | 'GCS' | 'Local' { if (USE_BLOB_STORAGE) return 'Azure Blob' if (USE_S3_STORAGE) return 'S3' + if (USE_GCS_STORAGE) return 'GCS' return 'Local' } /** - * Check if we're using any cloud storage (S3 or Blob) + * Check if we're using any cloud storage (S3, Blob, or GCS) */ export function isUsingCloudStorage(): boolean { - return USE_S3_STORAGE || USE_BLOB_STORAGE + return USE_S3_STORAGE || USE_BLOB_STORAGE || USE_GCS_STORAGE +} + +/** + * Provider segment used in `/api/files/serve//` paths returned to + * clients after a direct upload. Defaults to `s3` to preserve historical paths. + */ +export function getServeStoragePrefix(): 'blob' | 's3' | 'gcs' { + if (USE_BLOB_STORAGE) return 'blob' + if (USE_GCS_STORAGE) return 'gcs' + return 's3' } /** @@ -147,6 +192,10 @@ export function getStorageConfig(context: StorageContext): StorageConfig { return getS3Config(context) } + if (USE_GCS_STORAGE) { + return getGcsConfig(context) + } + return {} } @@ -277,6 +326,39 @@ function getBlobConfig(context: StorageContext): StorageConfig { } } +/** + * Get GCS configuration for a given context. + * + * Every context falls back to the general bucket when its dedicated bucket is + * unset. GCS bucket names are globally unique, so an S3-style literal default + * (e.g. `sim-execution-files`) would point at a bucket the operator does not + * own; falling back keeps uploads, downloads, and serving consistent when only + * `GCS_BUCKET_NAME` is configured. + */ +function getGcsConfig(context: StorageContext): StorageConfig { + switch (context) { + case 'knowledge-base': + return { bucket: GCS_KB_CONFIG.bucket || GCS_CONFIG.bucket } + case 'chat': + return { bucket: GCS_CHAT_CONFIG.bucket || GCS_CONFIG.bucket } + case 'copilot': + return { bucket: GCS_COPILOT_CONFIG.bucket || GCS_CONFIG.bucket } + case 'execution': + return { bucket: GCS_EXECUTION_FILES_CONFIG.bucket || GCS_CONFIG.bucket } + case 'mothership': + case 'workspace': + return { bucket: GCS_CONFIG.bucket } + case 'profile-pictures': + return { bucket: GCS_PROFILE_PICTURES_CONFIG.bucket || GCS_CONFIG.bucket } + case 'og-images': + return { bucket: GCS_OG_IMAGES_CONFIG.bucket || GCS_CONFIG.bucket } + case 'workspace-logos': + return { bucket: GCS_WORKSPACE_LOGOS_CONFIG.bucket || GCS_CONFIG.bucket } + default: + return { bucket: GCS_CONFIG.bucket } + } +} + /** * Check if a specific storage context is configured * Returns false if the context would fall back to general config but general isn't configured @@ -295,5 +377,9 @@ export function isStorageContextConfigured(context: StorageContext): boolean { return !!(config.bucket && config.region) } + if (USE_GCS_STORAGE) { + return !!config.bucket + } + return true } diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 736e14ab260..5c658928b55 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -123,7 +123,53 @@ describe('trackChatUpload', () => { expectNoWorkspaceStorageAccounting() }) + it('stamps message_id on the UPDATE arm when the birth message is known', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) + + await trackChatUpload( + WORKSPACE_ID, + USER_ID, + CHAT_ID, + S3_KEY, + 'image.png', + 'image/png', + 1024, + 'msg_abc' + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) + ) + }) + + it('stamps message_id on the fallback INSERT arm and nulls it when omitted', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await trackChatUpload( + WORKSPACE_ID, + USER_ID, + CHAT_ID, + S3_KEY, + 'image.png', + 'image/png', + 1024, + 'msg_abc' + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) + ) + + // Legacy callers without a message id write an explicit NULL ("birth unknown"). + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) + await trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024) + expect(dbChainMockFns.set).toHaveBeenLastCalledWith( + expect.objectContaining({ messageId: null }) + ) + }) + it('retries metadata naming without workspace storage accounting', async () => { + // 23505 from the partial unique index on (chat_id, display_name) — the case we retry. const displayNameCollision = Object.assign(new Error('duplicate key'), { code: '23505', constraint_name: CHAT_DISPLAY_NAME_INDEX, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 5a8e47f72b9..6d31fc10487 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -452,6 +452,14 @@ export async function ensureWorkspaceFileFolderPath(params: { }): Promise { if (params.pathSegments.length === 0) return null + // Fast path: the whole chain already exists (the common case for repeated + // writes into known folders) — per-segment indexed lookups instead of + // loading the workspace's entire folder table. + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { + includeReservedSystemFolders: true, + }) + if (existing) return existing + // Load all active folders once and build a lookup keyed by "name|parentId" // so we can resolve existing segments without a per-segment SELECT. const existingFolders = await db diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts new file mode 100644 index 00000000000..6b032412bd9 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const chain = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + } + chain.from.mockReturnValue(chain) + chain.where.mockReturnValue(chain) + return { + chain, + select: vi.fn(() => chain), + } +}) + +vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) + +import { listWorkspaceFiles } from './workspace-file-manager' + +describe('listWorkspaceFiles error handling', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.chain.from.mockReturnValue(mocks.chain) + mocks.chain.where.mockReturnValue(mocks.chain) + mocks.chain.orderBy.mockRejectedValue(new Error('database unavailable')) + mocks.select.mockReturnValue(mocks.chain) + }) + + it('keeps the established best-effort behavior by default', async () => { + await expect(listWorkspaceFiles('workspace-1')).resolves.toEqual([]) + }) + + it('propagates failures when a caller requires an authoritative list', async () => { + await expect(listWorkspaceFiles('workspace-1', { throwOnError: true })).rejects.toThrow( + 'database unavailable' + ) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index e84d4c4dc72..18d26c5f627 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -83,6 +83,8 @@ interface ListWorkspaceFilesOptions { folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean includeReservedSystemFiles?: boolean + /** Propagate storage errors when an incomplete list would be unsafe. */ + throwOnError?: boolean } /** @@ -591,14 +593,20 @@ export async function trackChatUpload( s3Key: string, fileName: string, contentType: string, - size: number + size: number, + messageId?: string ): Promise<{ displayName: string }> { for (let n = 1; n <= MAX_CHAT_DISPLAY_NAME_RETRIES; n++) { const candidate = suffixedName(fileName, n) try { const updated = await db .update(workspaceFiles) - .set({ chatId, context: 'mothership', displayName: candidate }) + .set({ + chatId, + messageId: messageId ?? null, + context: 'mothership', + displayName: candidate, + }) .where( and( eq(workspaceFiles.key, s3Key), @@ -624,6 +632,7 @@ export async function trackChatUpload( workspaceId, context: 'mothership', chatId, + messageId: messageId ?? null, originalName: fileName, displayName: candidate, contentType, @@ -794,6 +803,7 @@ export async function listWorkspaceFiles( }) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) + if (options?.throwOnError) throw error return [] } } @@ -1192,6 +1202,77 @@ export async function renameWorkspaceFile( } } +/** + * Move and/or rename a workspace file in one atomic row update. Either side + * may be a no-op (same folder = pure rename, same name = pure move); when + * both are unchanged the record is returned untouched. Conflicts at the + * destination throw {@link FileConflictError}. The `renamed`/`moved` flags + * report what actually changed, computed from the same read the update uses. + */ +export async function moveRenameWorkspaceFile(params: { + workspaceId: string + fileId: string + targetFolderId: string | null + newName: string +}): Promise<{ file: WorkspaceFileRecord; renamed: boolean; moved: boolean }> { + const normalizedName = normalizeWorkspaceFileItemName(params.newName.trim(), 'File') + + const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) + if (!fileRecord) { + throw new Error('File not found') + } + + const targetFolderId = await assertWorkspaceFileFolderTarget( + params.workspaceId, + params.targetFolderId + ) + const currentFolderId = fileRecord.folderId ?? null + const renamed = fileRecord.name !== normalizedName + const moved = currentFolderId !== targetFolderId + if (!renamed && !moved) { + return { file: fileRecord, renamed, moved } + } + + const exists = await fileExistsInWorkspace(params.workspaceId, normalizedName, targetFolderId) + if (exists) { + throw new FileConflictError(normalizedName) + } + + let updated: { id: string }[] + try { + updated = await db + .update(workspaceFiles) + .set({ originalName: normalizedName, folderId: targetFolderId, updatedAt: new Date() }) + .where( + and( + eq(workspaceFiles.id, params.fileId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace') + ) + ) + .returning({ id: workspaceFiles.id }) + } catch (error: unknown) { + if (getPostgresErrorCode(error) === '23505') { + throw new FileConflictError(normalizedName) + } + throw error + } + + if (updated.length === 0) { + throw new Error('File not found or could not be moved') + } + + return { + file: { + ...fileRecord, + name: normalizedName, + folderId: targetFolderId, + }, + renamed, + moved, + } +} + /** * Soft delete a workspace file. */ diff --git a/apps/sim/lib/uploads/core/setup.server.ts b/apps/sim/lib/uploads/core/setup.server.ts index 500eae73c1a..b446382e87d 100644 --- a/apps/sim/lib/uploads/core/setup.server.ts +++ b/apps/sim/lib/uploads/core/setup.server.ts @@ -7,6 +7,7 @@ import { getStorageProvider, S3_CONFIG, USE_BLOB_STORAGE, + USE_GCS_STORAGE, USE_S3_STORAGE, } from '@/lib/uploads/config' @@ -33,6 +34,11 @@ async function ensureUploadsDirectory() { return true } + if (USE_GCS_STORAGE) { + logger.info('Using Google Cloud Storage, skipping local uploads directory creation') + return true + } + try { if (!existsSync(UPLOAD_DIR_SERVER)) { await mkdir(UPLOAD_DIR_SERVER, { recursive: true }) @@ -94,6 +100,18 @@ if (typeof process !== 'undefined') { `Using S3-compatible endpoint: ${env.S3_ENDPOINT} (path-style: ${S3_CONFIG.forcePathStyle})` ) } + } else if (USE_GCS_STORAGE) { + // Verify GCS credentials + if (env.GCS_CREDENTIALS_JSON) { + logger.info('Using inline service-account credentials (GCS_CREDENTIALS_JSON)') + } else { + logger.info( + 'GCS_CREDENTIALS_JSON not set — using Application Default Credentials (Workload Identity or GOOGLE_APPLICATION_CREDENTIALS)' + ) + logger.info( + 'Signed URL generation without a private key requires the iam.serviceAccounts.signBlob permission (roles/iam.serviceAccountTokenCreator)' + ) + } } else { // Local storage mode logger.info('Using local file storage') @@ -121,4 +139,10 @@ if (typeof process !== 'undefined') { if (USE_S3_STORAGE && env.S3_COPILOT_BUCKET_NAME) { logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`) } + if (USE_GCS_STORAGE && env.GCS_KB_BUCKET_NAME) { + logger.info(`GCS knowledge base bucket: ${env.GCS_KB_BUCKET_NAME}`) + } + if (USE_GCS_STORAGE && env.GCS_COPILOT_BUCKET_NAME) { + logger.info(`GCS copilot bucket: ${env.GCS_COPILOT_BUCKET_NAME}`) + } } diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index 2ae29b8a69f..72a0683ebf9 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -1,4 +1,4 @@ -import { USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' +import { USE_BLOB_STORAGE, USE_GCS_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' import type { StorageConfig } from '@/lib/uploads/shared/types' export type { StorageConfig } from '@/lib/uploads/shared/types' @@ -6,9 +6,10 @@ export type { StorageConfig } from '@/lib/uploads/shared/types' /** * Get the current storage provider name */ -export function getStorageProvider(): 'blob' | 's3' | 'local' { +export function getStorageProvider(): 'blob' | 's3' | 'gcs' | 'local' { if (USE_BLOB_STORAGE) return 'blob' if (USE_S3_STORAGE) return 's3' + if (USE_GCS_STORAGE) return 'gcs' return 'local' } @@ -93,5 +94,10 @@ export async function getFileMetadata( return response.Metadata || {} } + if (USE_GCS_STORAGE) { + const { getGcsObjectMetadata } = await import('@/lib/uploads/providers/gcs/client') + return getGcsObjectMetadata(key, customConfig?.bucket ? { bucket: customConfig.bucket } : undefined) + } + return {} } diff --git a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts index dffb79607eb..3159d5e7139 100644 --- a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts +++ b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts @@ -21,6 +21,7 @@ const { mockHeadBlobObject, mockGetBlobServiceClient, mockGenerateBlobSASQueryPa vi.mock('@/lib/uploads/config', () => ({ USE_S3_STORAGE: false, USE_BLOB_STORAGE: true, + USE_GCS_STORAGE: false, // Connection-string-only: accountName/accountKey intentionally absent. getStorageConfig: () => ({ containerName: 'workspace-files', diff --git a/apps/sim/lib/uploads/core/storage-service.test.ts b/apps/sim/lib/uploads/core/storage-service.test.ts index 5343853c160..e2f39c44dbb 100644 --- a/apps/sim/lib/uploads/core/storage-service.test.ts +++ b/apps/sim/lib/uploads/core/storage-service.test.ts @@ -24,6 +24,7 @@ const { vi.mock('@/lib/uploads/config', () => ({ USE_S3_STORAGE: true, USE_BLOB_STORAGE: false, + USE_GCS_STORAGE: false, getStorageConfig: () => ({ bucket: 'b', region: 'r' }), })) diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index acfac9f3794..0a49d320e02 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -3,8 +3,14 @@ import { randomBytes } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' -import { getStorageConfig, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' +import { + getStorageConfig, + USE_BLOB_STORAGE, + USE_GCS_STORAGE, + USE_S3_STORAGE, +} from '@/lib/uploads/config' import type { AzureMultipartPart, BlobConfig } from '@/lib/uploads/providers/blob/types' +import type { GcsConfig, GcsMultipartPart } from '@/lib/uploads/providers/gcs/types' import type { S3Config, S3MultipartPart } from '@/lib/uploads/providers/s3/types' import type { DeleteFileOptions, @@ -62,6 +68,20 @@ function createS3Config(config: StorageConfig): S3Config { } } +/** + * Create a GCS config from StorageConfig + * @throws Error if required properties are missing + */ +function createGcsConfig(config: StorageConfig): GcsConfig { + if (!config.bucket) { + throw new Error('GCS configuration missing required property: bucket') + } + + return { + bucket: config.bucket, + } +} + /** * Insert file metadata into the database */ @@ -159,6 +179,32 @@ export async function uploadFile(options: UploadFileOptions): Promise return uploadResult } + if (USE_GCS_STORAGE) { + const { uploadToGcs } = await import('@/lib/uploads/providers/gcs/client') + const uploadResult = await uploadToGcs( + file, + keyToUse, + contentType, + createGcsConfig(config), + file.length, + preserveKey, + metadata + ) + + if (metadata && persistMetadata) { + await insertFileMetadataHelper( + uploadResult.key, + metadata, + context, + fileName, + contentType, + file.length + ) + } + + return uploadResult + } + const { writeFile, mkdir } = await import('fs/promises') const { join, dirname } = await import('path') const { UPLOAD_DIR_SERVER } = await import('./setup.server') @@ -262,6 +308,36 @@ async function createBlobBackend( } } +async function createGcsBackend( + key: string, + config: GcsConfig, + contentType: string, + purpose: string +): Promise { + const { + initiateGcsMultipartUpload, + uploadGcsPart, + completeGcsMultipartUpload, + abortGcsMultipartUpload, + } = await import('@/lib/uploads/providers/gcs/client') + const { uploadId } = await initiateGcsMultipartUpload({ + fileName: key, + contentType, + fileSize: 0, + customConfig: config, + customKey: key, + purpose, + }) + const parts: GcsMultipartPart[] = [] + return { + async uploadPart(partNumber, body) { + parts.push(await uploadGcsPart(key, uploadId, partNumber, body, config)) + }, + finish: () => completeGcsMultipartUpload(key, uploadId, parts, config).then(() => undefined), + abort: () => abortGcsMultipartUpload(key, uploadId, config), + } +} + /** * Open a streaming multipart upload to the configured provider. On the local * filesystem provider (and for any payload smaller than one part) the bytes are @@ -292,9 +368,13 @@ export async function createMultipartUpload(options: { const ensureBackend = async (): Promise => { if (!backend) { - backend = USE_BLOB_STORAGE - ? await createBlobBackend(key, createBlobConfig(config), contentType) - : await createS3Backend(key, createS3Config(config), contentType, context) + if (USE_BLOB_STORAGE) { + backend = await createBlobBackend(key, createBlobConfig(config), contentType) + } else if (USE_GCS_STORAGE) { + backend = await createGcsBackend(key, createGcsConfig(config), contentType, context) + } else { + backend = await createS3Backend(key, createS3Config(config), contentType, context) + } } return backend } @@ -393,6 +473,14 @@ export async function downloadFile(options: DownloadFileOptions): Promise { const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client') return deleteFromS3(key, createS3Config(config)) } + + if (USE_GCS_STORAGE) { + const { deleteFromGcs } = await import('@/lib/uploads/providers/gcs/client') + return deleteFromGcs(key, createGcsConfig(config)) + } } const { unlink } = await import('fs/promises') @@ -531,6 +629,11 @@ export async function headObject( return headS3Object(key, createS3Config(config)) } + if (USE_GCS_STORAGE) { + const { headGcsObject } = await import('@/lib/uploads/providers/gcs/client') + return headGcsObject(key, createGcsConfig(config)) + } + return null } @@ -586,9 +689,40 @@ export async function generatePresignedUploadUrl( return generateBlobPresignedUrl(key, contentType, allMetadata, config, expirationSeconds) } + if (USE_GCS_STORAGE) { + return generateGcsPresignedUrl(key, contentType, allMetadata, config, expirationSeconds) + } + throw new Error('Cloud storage not configured. Cannot generate presigned URL for local storage.') } +/** + * Generate presigned URL for GCS + */ +async function generateGcsPresignedUrl( + key: string, + contentType: string, + metadata: Record, + config: StorageConfig, + expirationSeconds: number +): Promise { + const { getGcsPresignedUploadUrl } = await import('@/lib/uploads/providers/gcs/client') + + const { url, signedHeaders } = await getGcsPresignedUploadUrl( + key, + contentType, + metadata, + createGcsConfig(config), + expirationSeconds + ) + + return { + url, + key, + uploadHeaders: signedHeaders, + } +} + /** * Generate presigned URL for S3 */ @@ -752,6 +886,11 @@ export async function generatePresignedDownloadUrl( return getPresignedUrlWithConfig(key, createBlobConfig(config), expirationSeconds) } + if (USE_GCS_STORAGE) { + const { getPresignedUrlWithConfig } = await import('@/lib/uploads/providers/gcs/client') + return getPresignedUrlWithConfig(key, createGcsConfig(config), expirationSeconds) + } + const { getBaseUrl } = await import('@/lib/core/utils/urls') const baseUrl = getBaseUrl() return `${baseUrl}/api/files/serve/${encodeURIComponent(key)}` @@ -761,7 +900,7 @@ export async function generatePresignedDownloadUrl( * Check if cloud storage is available */ export function hasCloudStorage(): boolean { - return USE_BLOB_STORAGE || USE_S3_STORAGE + return USE_BLOB_STORAGE || USE_S3_STORAGE || USE_GCS_STORAGE } /** diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts new file mode 100644 index 00000000000..58cebc3187a --- /dev/null +++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts @@ -0,0 +1,492 @@ +/** + * Tests for GCS client functionality + * + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFile, + mockBucket, + mockStorageInstance, + mockStorageConstructor, + mockGetAccessToken, + mockEnv, + mockGcsConfig, +} = vi.hoisted(() => { + const mockFile = { + save: vi.fn(), + createReadStream: vi.fn(), + getMetadata: vi.fn(), + delete: vi.fn(), + getSignedUrl: vi.fn(), + } + const mockBucket = { file: vi.fn(() => mockFile) } + const mockGetAccessToken = vi.fn() + const mockStorageInstance = { + bucket: vi.fn(() => mockBucket), + authClient: { getAccessToken: mockGetAccessToken }, + } + const mockEnv: Record = { + GCS_BUCKET_NAME: 'test-bucket', + } + const mockGcsConfig = { bucket: 'test-bucket' } + return { + mockFile, + mockBucket, + mockStorageInstance, + mockStorageConstructor: vi.fn().mockImplementation( + class { + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: vitest constructs mocks via Reflect.construct; returning the object overrides the instance so `new Storage()` yields the shared mock the tests assert on + return mockStorageInstance + } + } + ), + mockGetAccessToken, + mockEnv, + mockGcsConfig, + } +}) + +vi.mock('@google-cloud/storage', () => ({ + Storage: mockStorageConstructor, +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: mockEnv, + getEnv: (key: string) => mockEnv[key], + isTruthy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), + isFalsy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false, +})) + +vi.mock('@/lib/uploads/config', () => ({ + GCS_CONFIG: mockGcsConfig, +})) + +import { + abortGcsMultipartUpload, + completeGcsMultipartUpload, + deleteFromGcs, + downloadFromGcs, + getGcsClient, + getGcsMultipartPartUrls, + getGcsPresignedUploadUrl, + getPresignedUrlWithConfig, + headGcsObject, + initiateGcsMultipartUpload, + resetGcsClientForTesting, + uploadGcsPart, + uploadToGcs, +} from '@/lib/uploads/providers/gcs/client' + +const mockFetch = vi.fn() + +describe('GCS Client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockEnv.GCS_PROJECT_ID = undefined + mockEnv.GCS_CREDENTIALS_JSON = undefined + mockGetAccessToken.mockResolvedValue('test-access-token') + resetGcsClientForTesting() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + describe('getGcsClient', () => { + it('should initialize with Application Default Credentials when no inline credentials are set', async () => { + const client = await getGcsClient() + + expect(client).toBeDefined() + expect(mockStorageConstructor).toHaveBeenCalledWith({}) + }) + + it('should initialize with inline credentials and project id when provided', async () => { + mockEnv.GCS_PROJECT_ID = 'my-project' + mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({ + client_email: 'svc@my-project.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + }) + + await getGcsClient() + + expect(mockStorageConstructor).toHaveBeenCalledWith({ + projectId: 'my-project', + credentials: { + client_email: 'svc@my-project.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + }, + }) + }) + + it('should fall back to the project id embedded in the credentials JSON', async () => { + mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({ + client_email: 'svc@my-project.iam.gserviceaccount.com', + private_key: 'key', + project_id: 'embedded-project', + }) + + await getGcsClient() + + expect(mockStorageConstructor).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'embedded-project' }) + ) + }) + + it('should reject invalid credentials JSON', async () => { + mockEnv.GCS_CREDENTIALS_JSON = 'not-json' + + await expect(getGcsClient()).rejects.toThrow('GCS_CREDENTIALS_JSON is not valid JSON') + }) + + it('should reject credentials JSON missing required fields', async () => { + mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({ client_email: 'svc@example.com' }) + + await expect(getGcsClient()).rejects.toThrow( + 'GCS_CREDENTIALS_JSON must contain client_email and private_key' + ) + }) + + it('should cache the client across calls', async () => { + await getGcsClient() + await getGcsClient() + + expect(mockStorageConstructor).toHaveBeenCalledTimes(1) + }) + }) + + describe('uploadToGcs', () => { + it('should upload a file to GCS and return file info', async () => { + mockFile.save.mockResolvedValueOnce(undefined) + + const file = Buffer.from('test content') + const result = await uploadToGcs(file, 'test-file.txt', 'text/plain') + + expect(mockStorageInstance.bucket).toHaveBeenCalledWith('test-bucket') + expect(mockBucket.file).toHaveBeenCalledWith(expect.stringContaining('test-file.txt')) + expect(mockFile.save).toHaveBeenCalledWith(file, { + contentType: 'text/plain', + resumable: false, + metadata: { + metadata: expect.objectContaining({ + originalName: 'test-file.txt', + uploadedAt: expect.any(String), + }), + }, + }) + + expect(result).toEqual({ + path: expect.stringContaining('/api/files/serve/'), + key: expect.stringContaining('test-file.txt'), + name: 'test-file.txt', + size: file.length, + type: 'text/plain', + }) + }) + + it('should preserve the key when preserveKey is set', async () => { + mockFile.save.mockResolvedValueOnce(undefined) + + const file = Buffer.from('content') + const result = await uploadToGcs( + file, + 'workspace/ws-1/file.txt', + 'text/plain', + { bucket: 'custom-bucket' }, + file.length, + true + ) + + expect(mockStorageInstance.bucket).toHaveBeenCalledWith('custom-bucket') + expect(mockBucket.file).toHaveBeenCalledWith('workspace/ws-1/file.txt') + expect(result.key).toBe('workspace/ws-1/file.txt') + }) + + it('should handle upload errors', async () => { + mockFile.save.mockRejectedValueOnce(new Error('Upload failed')) + + await expect(uploadToGcs(Buffer.from('x'), 'f.txt', 'text/plain')).rejects.toThrow( + 'Upload failed' + ) + }) + }) + + describe('presigned URLs', () => { + it('should generate a v4 read signed URL', async () => { + mockFile.getSignedUrl.mockResolvedValueOnce(['https://example.com/signed-read']) + + const url = await getPresignedUrlWithConfig('test-file.txt', { bucket: 'custom' }, 1800) + + expect(mockStorageInstance.bucket).toHaveBeenCalledWith('custom') + expect(mockFile.getSignedUrl).toHaveBeenCalledWith({ + version: 'v4', + action: 'read', + expires: expect.any(Number), + }) + expect(url).toBe('https://example.com/signed-read') + }) + + it('should generate a v4 write signed URL with signed metadata headers', async () => { + mockFile.getSignedUrl.mockResolvedValueOnce(['https://example.com/signed-write']) + + const result = await getGcsPresignedUploadUrl( + 'workspace/file.txt', + 'text/plain', + { originalName: 'file.txt', workspaceId: 'ws-1' }, + { bucket: 'test-bucket' }, + 3600 + ) + + expect(mockFile.getSignedUrl).toHaveBeenCalledWith({ + version: 'v4', + action: 'write', + expires: expect.any(Number), + contentType: 'text/plain', + extensionHeaders: expect.objectContaining({ + 'x-goog-meta-originalName': 'file.txt', + 'x-goog-meta-workspaceId': 'ws-1', + }), + }) + expect(result.url).toBe('https://example.com/signed-write') + expect(result.signedHeaders).toEqual( + expect.objectContaining({ + 'Content-Type': 'text/plain', + 'x-goog-meta-workspaceId': 'ws-1', + }) + ) + }) + }) + + describe('downloadFromGcs', () => { + it('should download a file from GCS', async () => { + mockFile.createReadStream.mockReturnValueOnce( + Readable.from([Buffer.from('chunk1'), Buffer.from('chunk2')]) + ) + + const result = await downloadFromGcs('test-file.txt') + + expect(mockBucket.file).toHaveBeenCalledWith('test-file.txt') + expect(result).toBeInstanceOf(Buffer) + expect(result.toString()).toBe('chunk1chunk2') + }) + + it('should reject before streaming when the known size exceeds the limit', async () => { + mockFile.getMetadata.mockResolvedValueOnce([{ size: '1024' }]) + + await expect(downloadFromGcs('big.bin', { bucket: 'test-bucket' }, 10)).rejects.toThrow( + 'storage download exceeds maximum size' + ) + expect(mockFile.createReadStream).not.toHaveBeenCalled() + }) + + it('should handle download errors', async () => { + const failing = new Readable({ + read() { + this.destroy(new Error('Stream error')) + }, + }) + mockFile.createReadStream.mockReturnValueOnce(failing) + + await expect(downloadFromGcs('test-file.txt')).rejects.toThrow('Stream error') + }) + }) + + describe('headGcsObject', () => { + it('should return size and content type when the object exists', async () => { + mockFile.getMetadata.mockResolvedValueOnce([{ size: '2048', contentType: 'text/csv' }]) + + const result = await headGcsObject('data.csv') + + expect(result).toEqual({ size: 2048, contentType: 'text/csv' }) + }) + + it('should return null when the object is missing', async () => { + mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { code: 404 })) + + const result = await headGcsObject('missing.txt') + + expect(result).toBeNull() + }) + + it('should rethrow non-404 errors', async () => { + mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { code: 403 })) + + await expect(headGcsObject('secret.txt')).rejects.toThrow('Forbidden') + }) + }) + + describe('deleteFromGcs', () => { + it('should delete a file, ignoring missing objects', async () => { + mockFile.delete.mockResolvedValueOnce(undefined) + + await deleteFromGcs('test-file.txt') + + expect(mockBucket.file).toHaveBeenCalledWith('test-file.txt') + expect(mockFile.delete).toHaveBeenCalledWith({ ignoreNotFound: true }) + }) + + it('should handle delete errors', async () => { + mockFile.delete.mockRejectedValueOnce(new Error('Delete failed')) + + await expect(deleteFromGcs('test-file.txt')).rejects.toThrow('Delete failed') + }) + }) + + describe('multipart uploads (XML API)', () => { + it('should initiate a multipart upload and parse the UploadId', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + 'upload-123', + { status: 200 } + ) + ) + + const result = await initiateGcsMultipartUpload({ + fileName: 'large.csv', + contentType: 'text/csv', + fileSize: 100, + customKey: 'workspace/ws-1/large.csv', + purpose: 'workspace', + }) + + expect(result).toEqual({ uploadId: 'upload-123', key: 'workspace/ws-1/large.csv' }) + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://storage.googleapis.com/test-bucket/workspace/ws-1/large.csv?uploads') + expect(init.method).toBe('POST') + expect(init.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer test-access-token', + 'Content-Type': 'text/csv', + 'x-goog-meta-purpose': 'workspace', + }) + ) + }) + + it('should throw when the initiate response has no UploadId', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })) + + await expect( + initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 }) + ).rejects.toThrow('no UploadId in response') + }) + + it('should surface XML API errors with status details', async () => { + mockFetch.mockResolvedValueOnce( + new Response('denied', { + status: 403, + statusText: 'Forbidden', + }) + ) + + await expect( + initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 }) + ).rejects.toThrow('403 Forbidden') + }) + + it('should upload a part and return its ETag', async () => { + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 200, headers: { ETag: '"etag-1"' } }) + ) + + const part = await uploadGcsPart('key.csv', 'upload-123', 1, Buffer.from('data')) + + expect(part).toEqual({ PartNumber: 1, ETag: '"etag-1"' }) + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://storage.googleapis.com/test-bucket/key.csv?partNumber=1&uploadId=upload-123') + expect(init.method).toBe('PUT') + }) + + it('should throw when a part upload returns no ETag', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect(uploadGcsPart('key.csv', 'upload-123', 1, Buffer.from('data'))).rejects.toThrow( + 'no ETag' + ) + }) + + it('should generate signed part URLs covering partNumber and uploadId', async () => { + mockFile.getSignedUrl + .mockResolvedValueOnce(['https://example.com/part-1']) + .mockResolvedValueOnce(['https://example.com/part-2']) + + const urls = await getGcsMultipartPartUrls('key.csv', 'upload-123', [1, 2]) + + expect(urls).toEqual([ + { partNumber: 1, url: 'https://example.com/part-1' }, + { partNumber: 2, url: 'https://example.com/part-2' }, + ]) + expect(mockFile.getSignedUrl).toHaveBeenCalledWith({ + version: 'v4', + action: 'write', + expires: expect.any(Number), + queryParams: { partNumber: '1', uploadId: 'upload-123' }, + }) + }) + + it('should complete a multipart upload with sorted parts XML', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })) + + const result = await completeGcsMultipartUpload('kb/uuid-file.txt', 'upload-123', [ + { PartNumber: 2, ETag: '"etag-2"' }, + { PartNumber: 1, ETag: '"etag-1"' }, + ]) + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://storage.googleapis.com/test-bucket/kb/uuid-file.txt?uploadId=upload-123') + expect(init.method).toBe('POST') + expect(init.body).toBe( + '1"etag-1"2"etag-2"' + ) + expect(result).toEqual({ + location: 'https://storage.googleapis.com/test-bucket/kb/uuid-file.txt', + path: '/api/files/serve/kb%2Fuuid-file.txt', + key: 'kb/uuid-file.txt', + }) + }) + + it('should restore quotes on ETags stripped by the browser upload client', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })) + + await completeGcsMultipartUpload('key.csv', 'upload-123', [ + { PartNumber: 1, ETag: 'etag-1' }, + ]) + + const [, init] = mockFetch.mock.calls[0] + expect(init.body).toBe( + '1"etag-1"' + ) + }) + + it('should abort a multipart upload via DELETE', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })) + + await abortGcsMultipartUpload('key.csv', 'upload-123') + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://storage.googleapis.com/test-bucket/key.csv?uploadId=upload-123') + expect(init.method).toBe('DELETE') + }) + + it('should swallow abort errors', async () => { + mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500, statusText: 'ISE' })) + + await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).resolves.toBeUndefined() + }) + + it('should fail multipart calls when no access token is available', async () => { + mockGetAccessToken.mockResolvedValueOnce(null) + + await expect( + initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 }) + ).rejects.toThrow('Failed to obtain a Google Cloud access token') + }) + }) +}) diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts new file mode 100644 index 00000000000..27b248e01fd --- /dev/null +++ b/apps/sim/lib/uploads/providers/gcs/client.ts @@ -0,0 +1,586 @@ +import type { Readable } from 'node:stream' +import type { Storage } from '@google-cloud/storage' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { env } from '@/lib/core/config/env' +import { + assertKnownSizeWithinLimit, + readNodeStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { GCS_CONFIG } from '@/lib/uploads/config' +import type { + GcsConfig, + GcsMultipartPart, + GcsMultipartUploadInit, + GcsPartUploadUrl, +} from '@/lib/uploads/providers/gcs/types' +import type { FileInfo } from '@/lib/uploads/shared/types' +import { + sanitizeFilenameForMetadata, + sanitizeStorageMetadata, +} from '@/lib/uploads/utils/file-utils' +import { sanitizeFileName } from '@/executor/constants' + +const logger = createLogger('GcsClient') + +/** XML API host, also used to build canonical object URLs. */ +const GCS_XML_API_HOST = 'https://storage.googleapis.com' + +let _gcsClient: Storage | null = null + +/** + * Reset the cached GCS client. Only intended for use in tests. + */ +export function resetGcsClientForTesting(): void { + _gcsClient = null +} + +interface GcsInlineCredentials { + client_email: string + private_key: string + project_id?: string +} + +/** + * Parse the inline service-account JSON from `GCS_CREDENTIALS_JSON`. + * Returns null when the variable is unset (Application Default Credentials). + * @throws Error when the variable is set but not valid service-account JSON + */ +export function parseGcsCredentials(): GcsInlineCredentials | null { + if (!env.GCS_CREDENTIALS_JSON) return null + + let parsed: unknown + try { + parsed = JSON.parse(env.GCS_CREDENTIALS_JSON) + } catch { + throw new Error('GCS_CREDENTIALS_JSON is not valid JSON') + } + + const credentials = parsed as Partial + if (!credentials.client_email || !credentials.private_key) { + throw new Error('GCS_CREDENTIALS_JSON must contain client_email and private_key') + } + + return credentials as GcsInlineCredentials +} + +export async function getGcsClient(): Promise { + if (_gcsClient) return _gcsClient + + const { Storage } = await import('@google-cloud/storage') + const credentials = parseGcsCredentials() + + _gcsClient = new Storage({ + ...(env.GCS_PROJECT_ID || credentials?.project_id + ? { projectId: env.GCS_PROJECT_ID || credentials?.project_id } + : {}), + ...(credentials + ? { credentials: { client_email: credentials.client_email, private_key: credentials.private_key } } + : {}), + }) + + return _gcsClient +} + +/** + * Get an OAuth2 bearer token for authenticated XML API requests + * (multipart initiate/part/complete/abort have no JSON API equivalent). + */ +async function getGcsAccessToken(): Promise { + const storage = await getGcsClient() + const token = await storage.authClient.getAccessToken() + if (!token) { + throw new Error( + 'Failed to obtain a Google Cloud access token – check GCS_CREDENTIALS_JSON or Application Default Credentials.' + ) + } + return token +} + +/** Percent-encode an object key per path segment, preserving `/` separators. */ +function encodeObjectKey(key: string): string { + return key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') +} + +/** Canonical public-style URL for an object (not signed — used as a location reference). */ +function buildGcsObjectUrl(bucket: string, key: string): string { + return `${GCS_XML_API_HOST}/${bucket}/${encodeObjectKey(key)}` +} + +/** + * Upload a file to Google Cloud Storage + * @param file Buffer containing file data + * @param fileName Original file name + * @param contentType MIME type of the file + * @param configOrSize Custom GCS configuration OR file size in bytes (optional) + * @param size File size in bytes (required if configOrSize is GcsConfig, optional otherwise) + * @param preserveKey Preserve the fileName as the storage key without adding timestamp prefix (default: false) + * @param metadata Optional metadata to store with the file + * @returns Object with file information + */ +export async function uploadToGcs( + file: Buffer, + fileName: string, + contentType: string, + configOrSize?: GcsConfig | number, + size?: number, + preserveKey?: boolean, + metadata?: Record +): Promise { + let config: GcsConfig + let fileSize: number + let shouldPreserveKey: boolean + + if (typeof configOrSize === 'object') { + config = configOrSize + fileSize = size ?? file.length + shouldPreserveKey = preserveKey ?? false + } else { + config = { bucket: GCS_CONFIG.bucket } + fileSize = configOrSize ?? file.length + shouldPreserveKey = preserveKey ?? false + } + + const safeFileName = sanitizeFileName(fileName) + const uniqueKey = shouldPreserveKey ? fileName : `${Date.now()}-${safeFileName}` + + const storage = await getGcsClient() + + const gcsMetadata: Record = { + originalName: sanitizeFilenameForMetadata(fileName), + uploadedAt: new Date().toISOString(), + } + + if (metadata) { + Object.assign(gcsMetadata, sanitizeStorageMetadata(metadata, 8000)) + } + + await storage.bucket(config.bucket).file(uniqueKey).save(file, { + contentType, + resumable: false, + metadata: { metadata: gcsMetadata }, + }) + + const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}` + + return { + path: servePath, + key: uniqueKey, + name: fileName, + size: fileSize, + type: contentType, + } +} + +/** + * Generate a presigned URL for direct file access + * @param key GCS object key + * @param expiresIn Time in seconds until URL expires + * @returns Presigned URL + */ +export async function getPresignedUrl(key: string, expiresIn = 3600) { + return getPresignedUrlWithConfig(key, { bucket: GCS_CONFIG.bucket }, expiresIn) +} + +/** + * Generate a presigned URL for direct file access with custom bucket + * @param key GCS object key + * @param customConfig Custom GCS configuration + * @param expiresIn Time in seconds until URL expires + * @returns Presigned URL + */ +export async function getPresignedUrlWithConfig( + key: string, + customConfig: GcsConfig, + expiresIn = 3600 +) { + const storage = await getGcsClient() + const [url] = await storage + .bucket(customConfig.bucket) + .file(key) + .getSignedUrl({ + version: 'v4', + action: 'read', + expires: Date.now() + expiresIn * 1000, + }) + return url +} + +/** + * Generate a presigned URL for direct file upload. `signedHeaders` must be sent + * verbatim by the uploader — they are covered by the V4 signature. + */ +export async function getGcsPresignedUploadUrl( + key: string, + contentType: string, + metadata: Record, + customConfig: GcsConfig, + expirationSeconds: number +): Promise<{ url: string; signedHeaders: Record }> { + const storage = await getGcsClient() + + const sanitizedMetadata = sanitizeStorageMetadata(metadata, 8000) + if (sanitizedMetadata.originalName) { + sanitizedMetadata.originalName = sanitizeFilenameForMetadata(sanitizedMetadata.originalName) + } + + const metadataHeaders = Object.entries(sanitizedMetadata).reduce( + (acc, [k, v]) => { + acc[`x-goog-meta-${k}`] = encodeURIComponent(v) + return acc + }, + {} as Record + ) + + const [url] = await storage + .bucket(customConfig.bucket) + .file(key) + .getSignedUrl({ + version: 'v4', + action: 'write', + expires: Date.now() + expirationSeconds * 1000, + contentType, + extensionHeaders: metadataHeaders, + }) + + return { + url, + signedHeaders: { + 'Content-Type': contentType, + ...metadataHeaders, + }, + } +} + +/** + * Download a file from Google Cloud Storage + * @param key GCS object key + * @returns File buffer + */ +export async function downloadFromGcs(key: string): Promise + +/** + * Download a file from Google Cloud Storage with custom bucket configuration + * @param key GCS object key + * @param customConfig Custom GCS configuration + * @returns File buffer + */ +export async function downloadFromGcs(key: string, customConfig: GcsConfig): Promise + +export async function downloadFromGcs( + key: string, + customConfig: GcsConfig, + maxBytes: number +): Promise + +export async function downloadFromGcs( + key: string, + customConfig?: GcsConfig, + maxBytes?: number +): Promise { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + const file = storage.bucket(config.bucket).file(key) + + if (maxBytes !== undefined) { + const [fileMetadata] = await file.getMetadata() + const knownSize = Number(fileMetadata.size) + if (Number.isFinite(knownSize)) { + assertKnownSizeWithinLimit(knownSize, maxBytes, 'storage download') + } + } + + return readNodeStreamToBufferWithLimit(file.createReadStream(), { + maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER, + label: 'storage download', + }) +} + +/** + * Stream an object out of GCS without buffering it. The caller MUST fully consume or + * `destroy()` the returned stream. Used by the large-CSV import worker so a 1M-row file is + * never resident in memory. + */ +export async function downloadFromGcsStream( + key: string, + customConfig?: GcsConfig +): Promise { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + return storage.bucket(config.bucket).file(key).createReadStream() +} + +/** + * Check whether an object exists in GCS (and return its size when it does). + * Returns null when the object is missing. + */ +export async function headGcsObject( + key: string, + customConfig?: GcsConfig +): Promise<{ size: number; contentType?: string } | null> { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + + try { + const [fileMetadata] = await storage.bucket(config.bucket).file(key).getMetadata() + return { + size: Number(fileMetadata.size) || 0, + contentType: fileMetadata.contentType, + } + } catch (error) { + const code = (error as { code?: number } | null)?.code + if (code === 404) { + return null + } + throw error + } +} + +/** + * Get the custom metadata stored on a GCS object. + */ +export async function getGcsObjectMetadata( + key: string, + customConfig?: GcsConfig +): Promise> { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + const [fileMetadata] = await storage.bucket(config.bucket).file(key).getMetadata() + return (fileMetadata.metadata as Record | undefined) || {} +} + +/** + * Delete a file from Google Cloud Storage + * @param key GCS object key + */ +export async function deleteFromGcs(key: string): Promise + +/** + * Delete a file from Google Cloud Storage with custom bucket configuration + * @param key GCS object key + * @param customConfig Custom GCS configuration + */ +export async function deleteFromGcs(key: string, customConfig: GcsConfig): Promise + +export async function deleteFromGcs(key: string, customConfig?: GcsConfig): Promise { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + await storage.bucket(config.bucket).file(key).delete({ ignoreNotFound: true }) +} + +/** + * Normalize an ETag to the quoted form GCS expects in CompleteMultipartUpload. + * The shared browser upload client strips quotes from part ETags (S3 tolerates + * either form), so quotes are restored here before building the completion XML. + */ +function normalizeEtag(etag: string): string { + return etag.startsWith('"') ? etag : `"${etag}"` +} + +/** Escape a value for embedding in an XML text node. */ +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * Perform an authenticated request against the GCS XML API. Multipart uploads + * (initiate/part/complete/abort) exist only on the XML API — the JSON API has + * no equivalent — so these calls go through fetch with a bearer token instead + * of the SDK. + */ +async function gcsXmlApiRequest( + method: 'POST' | 'PUT' | 'DELETE', + bucket: string, + key: string, + query: string, + options?: { headers?: Record; body?: Buffer | string } +): Promise { + const token = await getGcsAccessToken() + const url = `${buildGcsObjectUrl(bucket, key)}?${query}` + + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...options?.headers, + }, + // Buffer is a valid BodyInit at runtime; undici's types only admit ArrayBufferView + body: options?.body as BodyInit | undefined, + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + throw new Error( + `GCS XML API ${method} ${query.split('&')[0]} failed for ${key}: ${response.status} ${response.statusText}${errorBody ? ` – ${errorBody.slice(0, 500)}` : ''}` + ) + } + + return response +} + +/** + * Initiate a multipart upload for GCS (XML API, S3-compatible semantics) + */ +export async function initiateGcsMultipartUpload( + options: GcsMultipartUploadInit +): Promise<{ uploadId: string; key: string }> { + const { fileName, contentType, customConfig, customKey, purpose } = options + + const config = customConfig || { bucket: GCS_CONFIG.bucket } + + const safeFileName = sanitizeFileName(fileName) + const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}` + + const response = await gcsXmlApiRequest('POST', config.bucket, uniqueKey, 'uploads', { + headers: { + 'Content-Type': contentType, + 'x-goog-meta-originalname': encodeURIComponent(sanitizeFilenameForMetadata(fileName)), + 'x-goog-meta-uploadedat': new Date().toISOString(), + 'x-goog-meta-purpose': purpose || 'knowledge-base', + }, + }) + + const xml = await response.text() + const uploadIdMatch = xml.match(/([^<]+)<\/UploadId>/) + if (!uploadIdMatch) { + throw new Error('Failed to initiate GCS multipart upload: no UploadId in response') + } + + return { + uploadId: uploadIdMatch[1], + key: uniqueKey, + } +} + +/** + * Upload a single multipart part from the server (Body in hand), returning its + * `{ PartNumber, ETag }`. The presigned variant ({@link getGcsMultipartPartUrls}) + * is for browser uploads; this is the server-side streaming path. + */ +export async function uploadGcsPart( + key: string, + uploadId: string, + partNumber: number, + body: Buffer, + customConfig?: GcsConfig +): Promise { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const response = await gcsXmlApiRequest( + 'PUT', + config.bucket, + key, + `partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`, + { body } + ) + + const etag = response.headers.get('etag') + if (!etag) { + throw new Error(`GCS part upload returned no ETag for part ${partNumber} of ${key}`) + } + return { PartNumber: partNumber, ETag: etag } +} + +/** + * Generate presigned URLs for uploading parts to GCS. The URLs sign the + * `partNumber`/`uploadId` query parameters (V4), matching the S3 flow — + * the browser PUTs each chunk and collects the returned ETags. + */ +export async function getGcsMultipartPartUrls( + key: string, + uploadId: string, + partNumbers: number[], + customConfig?: GcsConfig +): Promise { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const storage = await getGcsClient() + const file = storage.bucket(config.bucket).file(key) + + return Promise.all( + partNumbers.map(async (partNumber) => { + const [url] = await file.getSignedUrl({ + version: 'v4', + action: 'write', + expires: Date.now() + 3600 * 1000, + queryParams: { + partNumber: String(partNumber), + uploadId, + }, + }) + return { partNumber, url } + }) + ) +} + +/** + * Complete multipart upload for GCS + */ +export async function completeGcsMultipartUpload( + key: string, + uploadId: string, + parts: GcsMultipartPart[], + customConfig?: GcsConfig +): Promise<{ location: string; path: string; key: string }> { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + + const sortedParts = [...parts].sort((a, b) => a.PartNumber - b.PartNumber) + const partsXml = sortedParts + .map( + (part) => + `${part.PartNumber}${escapeXml(normalizeEtag(part.ETag))}` + ) + .join('') + const body = `${partsXml}` + + const response = await gcsXmlApiRequest( + 'POST', + config.bucket, + key, + `uploadId=${encodeURIComponent(uploadId)}`, + { + headers: { 'Content-Type': 'application/xml' }, + body, + } + ) + + // The S3 XML dialect permits a 200 response carrying an error document; GCS + // does not document that behavior, but checking is cheap insurance against + // reporting a failed completion as success. + const responseXml = await response.text() + if (responseXml.includes(' { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + try { + await gcsXmlApiRequest( + 'DELETE', + config.bucket, + key, + `uploadId=${encodeURIComponent(uploadId)}` + ) + } catch (error) { + logger.warn('Error cleaning up GCS multipart upload:', error) + } +} diff --git a/apps/sim/lib/uploads/providers/gcs/types.ts b/apps/sim/lib/uploads/providers/gcs/types.ts new file mode 100644 index 00000000000..4a54bf3c1d3 --- /dev/null +++ b/apps/sim/lib/uploads/providers/gcs/types.ts @@ -0,0 +1,30 @@ +export interface GcsConfig { + bucket: string +} + +export interface GcsMultipartUploadInit { + fileName: string + contentType: string + fileSize: number + customConfig?: GcsConfig + /** + * When provided, overrides the default `kb/${id}-${name}` key derivation. + * Caller is responsible for uniqueness and prefix conventions. + */ + customKey?: string + /** + * Storage purpose tag persisted as object metadata. Defaults to `knowledge-base` + * for backwards compatibility. + */ + purpose?: string +} + +export interface GcsPartUploadUrl { + partNumber: number + url: string +} + +export interface GcsMultipartPart { + ETag: string + PartNumber: number +} diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts index c8773883b70..bec0f9e936b 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -14,6 +14,7 @@ describe('extractEmbeddedFileRef', () => { }) expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY }) expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY }) + expect(extractEmbeddedFileRef(`/api/files/serve/gcs/${ENCODED}`)).toEqual({ key: KEY }) }) it('parses view-url and in-app-path embeds to the file id', () => { diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index 0161af11ea7..7e780362ea5 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -24,7 +24,7 @@ const EMBED_URL_RE = /** * Parse a single embed `src` into the workspace file it references, normalizing the spellings the - * editor and file agent produce: `/api/files/serve/` (incl. `s3/`/`blob/` prefixes), `/api/files/view/`, + * editor and file agent produce: `/api/files/serve/` (incl. `s3/`/`blob/`/`gcs/` prefixes), `/api/files/view/`, * and the in-app path `/workspace//files/`. Returns null for absolute, `data:`, or non-workspace * URLs (e.g. public `profile-pictures/` assets), which render as-is. */ @@ -35,7 +35,9 @@ export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { const segs = parsed.pathname.split('/') if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') { let keySegs = segs.slice(4) - if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1) + if (keySegs[0] === 's3' || keySegs[0] === 'blob' || keySegs[0] === 'gcs') { + keySegs = keySegs.slice(1) + } const raw = keySegs.join('/') if (!raw) return null const key = decodeURIComponent(raw) diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 0e7581bb454..7c9e62d9504 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { describe, expect, it } from 'vitest' import { + extractStorageKey, inferContextFromKey, isAbortError, isInternalFileUrl, @@ -14,6 +15,24 @@ import { const logger = createLogger('FileUtilsTest') +describe('extractStorageKey', () => { + it('strips every provider serve prefix', () => { + expect(extractStorageKey('/api/files/serve/s3/workspace%2Fws-1%2Ffile.txt')).toBe( + 'workspace/ws-1/file.txt' + ) + expect(extractStorageKey('/api/files/serve/blob/workspace%2Fws-1%2Ffile.txt')).toBe( + 'workspace/ws-1/file.txt' + ) + expect(extractStorageKey('/api/files/serve/gcs/workspace%2Fws-1%2Ffile.txt')).toBe( + 'workspace/ws-1/file.txt' + ) + }) + + it('returns unprefixed serve keys as-is', () => { + expect(extractStorageKey('/api/files/serve/kb/123-doc.pdf')).toBe('kb/123-doc.pdf') + }) +}) + describe('isInternalFileUrl', () => { it('classifies relative serve paths as internal', () => { expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 642e48aafd2..2e0f0d57cb2 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -532,6 +532,8 @@ export function extractStorageKey(filePath: string): string { key = key.substring(3) } else if (key.startsWith('blob/')) { key = key.substring(5) + } else if (key.startsWith('gcs/')) { + key = key.substring(4) } return key } diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 0098efc2470..1bc82ac6261 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -1,8 +1,12 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { UserSettingsApi } from '@/lib/api/contracts/user' +const logger = createLogger('UserQueries') + /** * Default user settings returned for unauthenticated users or when no * settings row exists yet. @@ -76,6 +80,26 @@ export async function getUserSettings(userId: string | null): Promise { + try { + const [userRecord] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + return userRecord?.email ?? null + } catch (error) { + logger.warn('Failed to load user email', { userId, error: getErrorMessage(error) }) + return null + } +} + /** * Loads a user's public profile fields, or `null` when no matching user exists. */ diff --git a/apps/sim/lib/webhooks/delivery-predicate.test.ts b/apps/sim/lib/webhooks/delivery-predicate.test.ts new file mode 100644 index 00000000000..44033a21745 --- /dev/null +++ b/apps/sim/lib/webhooks/delivery-predicate.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAnd, mockEq, mockIsNull } = vi.hoisted(() => ({ + mockAnd: vi.fn((...conditions: unknown[]) => ({ kind: 'and', conditions })), + mockEq: vi.fn((column: unknown, value: unknown) => ({ kind: 'eq', column, value })), + mockIsNull: vi.fn((column: unknown) => ({ kind: 'isNull', column })), +})) + +vi.mock('drizzle-orm', () => ({ + and: mockAnd, + eq: mockEq, + isNull: mockIsNull, +})) + +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' + +const columns = { + isActive: 'webhook.isActive', + archivedAt: 'webhook.archivedAt', +} as unknown as Parameters[0] + +describe('deliverableWebhookPredicate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the active, non-archived legacy delivery predicate by default', () => { + const predicate = deliverableWebhookPredicate(columns) + + expect(mockEq).toHaveBeenCalledWith('webhook.isActive', true) + expect(mockIsNull).toHaveBeenCalledWith('webhook.archivedAt') + expect(mockAnd).toHaveBeenCalledWith( + { kind: 'eq', column: 'webhook.isActive', value: true }, + { kind: 'isNull', column: 'webhook.archivedAt' } + ) + expect(predicate).toEqual({ + kind: 'and', + conditions: [ + { kind: 'eq', column: 'webhook.isActive', value: true }, + { kind: 'isNull', column: 'webhook.archivedAt' }, + ], + }) + }) + + it('preserves active-only behavior for legacy consumers that included archived rows', () => { + const predicate = deliverableWebhookPredicate(columns, 'active_only') + + expect(predicate).toEqual({ kind: 'eq', column: 'webhook.isActive', value: true }) + expect(mockIsNull).not.toHaveBeenCalled() + expect(mockAnd).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/delivery-predicate.ts b/apps/sim/lib/webhooks/delivery-predicate.ts new file mode 100644 index 00000000000..d652b0c8321 --- /dev/null +++ b/apps/sim/lib/webhooks/delivery-predicate.ts @@ -0,0 +1,21 @@ +import type { webhook } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' + +type WebhookDeliveryColumns = Pick + +export type LegacyWebhookDeliveryPolicy = 'active_unarchived' | 'active_only' + +/** + * Builds the current webhook-row delivery predicate without assuming future lifecycle columns. + * + * Most delivery consumers exclude archived rows. The active-only policy exists solely to preserve + * legacy consumers that historically scanned archived active rows, such as subscription renewal. + */ +export function deliverableWebhookPredicate( + columns: WebhookDeliveryColumns, + policy: LegacyWebhookDeliveryPolicy = 'active_unarchived' +) { + const activePredicate = eq(columns.isActive, true) + if (policy === 'active_only') return activePredicate + return and(activePredicate, isNull(columns.archivedAt)) +} diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 60decead333..c866ee7d9a7 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,17 +1,24 @@ import { db } from '@sim/db' import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { WebhookPathClaimConflictError } from '@/lib/webhooks/path-claims' import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' import { cleanupExternalWebhook, createExternalWebhookSubscription, hasWebhookConfigChanged, + projectDesiredWebhookProviderConfig, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' +import { + prepareStableWebhookRegistrations, + type StableDesiredWebhookRegistration, +} from '@/lib/webhooks/registration-service' import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { buildCanonicalIndex, @@ -50,6 +57,17 @@ interface BuiltProviderConfig { triggerPath: string } +interface ResolvedWebhookConfig { + provider: string + providerConfig: Record + triggerPath: string | null + routingKey: string | null +} + +type ResolveWebhookConfigResult = + | { success: true; config: ResolvedWebhookConfig } + | { success: false; error: TriggerSaveError } + export async function validateTriggerWebhookConfigForDeploy( blocks: Record ): Promise { @@ -339,6 +357,189 @@ async function resolveTriggerCredentialId( return resolvedCredential?.id ?? null } +async function resolveWebhookConfigForBlock(input: { + block: BlockState + workflow: Record + userId: string + requestId: string +}): Promise { + const triggerId = resolveTriggerId(input.block) + if (!triggerId || !isTriggerValid(triggerId)) return null + + const triggerDef = getTrigger(triggerId) + const { providerConfig, missingFields, credentialReference, credentialServiceId, triggerPath } = + buildProviderConfig(input.block, triggerId, triggerDef) + + if (missingFields.length > 0) { + return { + success: false, + error: { + message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`, + status: 400, + }, + } + } + + if (providerConfig.requireAuth && !providerConfig.token) { + return { + success: false, + error: { + message: + 'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.', + status: 400, + }, + } + } + + let credentialId: string | undefined + if (credentialReference && credentialServiceId) { + const workflowWorkspaceId = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!workflowWorkspaceId) { + return { + success: false, + error: { + message: `Cannot validate credentials for ${triggerDef.name || triggerId} without a workflow workspace`, + status: 400, + }, + } + } + + credentialId = + (await resolveTriggerCredentialId( + credentialReference, + workflowWorkspaceId, + credentialServiceId + )) ?? undefined + if (!credentialId) { + return { + success: false, + error: { + message: `The selected ${credentialServiceId} credential is not available in this workspace`, + status: 400, + }, + } + } + providerConfig.credentialId = credentialId + } + + let effectiveProvider = triggerDef.provider + let effectivePath: string | null = triggerPath + let routingKey: string | null = null + if (triggerId === 'slack_oauth') { + const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom' + if (appType === 'sim') { + const eventType = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { + return { + success: false, + error: { + message: + 'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.', + status: 400, + }, + } + } + if (!credentialId) { + return { + success: false, + error: { message: 'Select a Slack account for the trigger.', status: 400 }, + } + } + + let tokenOwnerUserId = input.userId + const resolvedAccount = await resolveOAuthAccountId(credentialId) + if (resolvedAccount?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolvedAccount.accountId)) + .limit(1) + if (owner?.userId) tokenOwnerUserId = owner.userId + } + const botToken = await refreshAccessTokenIfNeeded( + credentialId, + tokenOwnerUserId, + input.requestId + ) + if (!botToken) { + return { + success: false, + error: { + message: 'Could not access the connected Slack account. Reconnect it and try again.', + status: 400, + }, + } + } + try { + const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) + routingKey = teamId + if (botUserId) providerConfig.bot_user_id = botUserId + } catch (error: unknown) { + logger.error( + `[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`, + error + ) + return { + success: false, + error: { + message: 'Could not verify the connected Slack workspace. Reconnect it and try again.', + status: 400, + }, + } + } + effectiveProvider = 'slack_app' + effectivePath = null + } else { + const botCredentialId = + typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined + if (!botCredentialId) { + return { + success: false, + error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, + } + } + const botCredential = await getSlackBotCredential(botCredentialId) + if (!botCredential) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', + status: 400, + }, + } + } + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is not available in this workspace.', + status: 400, + }, + } + } + effectiveProvider = 'slack' + effectivePath = null + routingKey = botCredentialId + providerConfig.credentialId = botCredentialId + if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId + } + } + + return { + success: true, + config: { + provider: effectiveProvider, + providerConfig, + triggerPath: effectivePath, + routingKey, + }, + } +} + async function configurePollingIfNeeded( provider: string, savedWebhook: Record, @@ -361,6 +562,95 @@ async function configurePollingIfNeeded( return null } +export interface PrepareStableTriggerWebhooksInput { + request: NextRequest + workflowId: string + workflow: Record + userId: string + blocks: Record + requestId: string + deploymentVersionId: string + operationId: string + generation: number + signal?: AbortSignal +} + +/** + * Prepares stable webhook registrations for the v2 deployment operation protocol. + * + * The legacy save path remains available below and retains its existing execution behavior. + */ +export async function prepareStableTriggerWebhooksForDeploy({ + request, + workflowId, + workflow, + userId, + blocks, + requestId, + deploymentVersionId, + operationId, + generation, + signal, +}: PrepareStableTriggerWebhooksInput): Promise { + const validationResult = await validateTriggerWebhookConfigForDeploy(blocks) + if (!validationResult.success) return validationResult + + const desired: StableDesiredWebhookRegistration[] = [] + const triggerBlocks = Object.values(blocks || {}).filter( + (block) => block && block.enabled !== false + ) + for (const block of triggerBlocks) { + signal?.throwIfAborted() + const resolved = await resolveWebhookConfigForBlock({ + block, + workflow, + userId, + requestId, + }) + if (!resolved) continue + if (!resolved.success) return resolved + + desired.push({ + blockId: block.id, + provider: resolved.config.provider, + path: resolved.config.triggerPath, + routingKey: resolved.config.routingKey, + providerConfig: resolved.config.providerConfig, + desiredConfig: projectDesiredWebhookProviderConfig(resolved.config.providerConfig), + }) + } + + try { + await prepareStableWebhookRegistrations({ + request, + fence: { workflowId, deploymentVersionId, operationId, generation }, + workflow, + userId, + requestId, + desired, + signal, + }) + return { success: true } + } catch (error) { + if (error instanceof WebhookPathClaimConflictError) { + return { + success: false, + error: { + message: `Webhook path "${error.path}" is already in use. Choose a different path.`, + status: 409, + }, + } + } + return { + success: false, + error: { + message: getErrorMessage(error, 'Failed to prepare webhook registrations'), + status: 500, + }, + } + } +} + /** * Saves trigger webhook configurations as part of workflow deployment. * Uses delete + create approach for changed/deleted webhooks. @@ -411,227 +701,42 @@ export async function saveTriggerWebhooksForDeploy({ existingWebhookBlockIds: Array.from(webhooksByBlockId.keys()), }) - type WebhookConfig = { - provider: string - providerConfig: Record - triggerPath: string | null - routingKey: string | null - triggerDef: ReturnType - } - const webhookConfigs = new Map() + const webhookConfigs = new Map() const webhooksToDelete: typeof existingWebhooks = [] const blocksNeedingWebhook: BlockState[] = [] for (const block of triggerBlocks) { - const triggerId = resolveTriggerId(block) - if (!triggerId || !isTriggerValid(triggerId)) continue - - const triggerDef = getTrigger(triggerId) - const provider = triggerDef.provider - const { providerConfig, missingFields, credentialReference, credentialServiceId, triggerPath } = - buildProviderConfig(block, triggerId, triggerDef) - - if (missingFields.length > 0) { - return { - success: false, - error: { - message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`, - status: 400, - }, - } - } - - if (providerConfig.requireAuth && !providerConfig.token) { - return { - success: false, - error: { - message: - 'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.', - status: 400, - }, - } - } - - let credentialId: string | undefined - if (credentialReference && credentialServiceId) { - const workflowWorkspaceId = - typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined - if (!workflowWorkspaceId) { - return { - success: false, - error: { - message: `Cannot validate credentials for ${triggerDef.name || triggerId} without a workflow workspace`, - status: 400, - }, - } - } - - credentialId = - (await resolveTriggerCredentialId( - credentialReference, - workflowWorkspaceId, - credentialServiceId - )) ?? undefined - if (!credentialId) { - return { - success: false, - error: { - message: `The selected ${credentialServiceId} credential is not available in this workspace`, - status: 400, - }, - } - } - providerConfig.credentialId = credentialId - } - - /** - * The unified Slack trigger (`slack_oauth`) resolves to one of two backends - * by App Type: `sim` routes inbound events on the official Sim app by Slack - * `team_id` (routingKey, no path); `custom` routes by the reusable bot - * credential id. The team_id is derived here from the connected account via - * `auth.test` — never from user input. - */ - let effectiveProvider = provider - let effectivePath: string | null = triggerPath - let routingKey: string | null = null - if (triggerId === 'slack_oauth') { - // Absent appType means custom: it's the only mode this ship exposes (the - // hidden selector seeds/persists 'custom'), and defaulting to sim would - // send credential-less configs down the OAuth/team-id branch. - const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom' - if (appType === 'sim') { - const eventType = - typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null - if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { - return { - success: false, - error: { - message: - 'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.', - status: 400, - }, - } - } - if (!credentialId) { - return { - success: false, - error: { message: 'Select a Slack account for the trigger.', status: 400 }, - } - } - // Resolve the credential OWNER's token (not the deploying actor's) — - // in a shared workspace a teammate can deploy a trigger wired to - // someone else's Slack credential. Mirrors the runtime formatInput path. - let tokenOwnerUserId = userId - const resolvedAccount = await resolveOAuthAccountId(credentialId) - if (resolvedAccount?.accountId) { - const [owner] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, resolvedAccount.accountId)) - .limit(1) - if (owner?.userId) tokenOwnerUserId = owner.userId - } - const botToken = await refreshAccessTokenIfNeeded(credentialId, tokenOwnerUserId, requestId) - if (!botToken) { - return { - success: false, - error: { - message: 'Could not access the connected Slack account. Reconnect it and try again.', - status: 400, - }, - } - } - try { - const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) - routingKey = teamId - if (botUserId) providerConfig.bot_user_id = botUserId - } catch (error: unknown) { - logger.error(`[${requestId}] Slack team_id resolution failed for ${block.id}`, error) - return { - success: false, - error: { - message: - 'Could not verify the connected Slack workspace. Reconnect it and try again.', - status: 400, - }, - } - } - effectiveProvider = 'slack_app' - effectivePath = null - } else { - // Custom: a reusable bring-your-own bot credential. Route by the - // credential id (one shared ingest URL per bot) instead of a per-workflow - // path, so multiple triggers on the same bot share one Request URL. - const botCredentialId = - typeof providerConfig.botCredential === 'string' - ? providerConfig.botCredential - : undefined - if (!botCredentialId) { - return { - success: false, - error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, - } - } - const botCredential = await getSlackBotCredential(botCredentialId) - if (!botCredential) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', - status: 400, - }, - } - } - // The credential must belong to the workflow's workspace: bot credential - // ids are semi-public (they're embedded in Slack Request URLs), so a - // pasted foreign id must never bind another tenant's bot to this - // workflow. - const workflowWorkspace = - typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined - if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is not available in this workspace.', - status: 400, - }, - } - } - effectiveProvider = 'slack' - effectivePath = null - routingKey = botCredentialId - providerConfig.credentialId = botCredentialId - if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId - } - } + const resolved = await resolveWebhookConfigForBlock({ + block, + workflow, + userId, + requestId, + }) + if (!resolved) continue + if (!resolved.success) return resolved + const { provider, providerConfig, triggerPath, routingKey } = resolved.config - if (effectivePath) { + if (triggerPath) { const pathConflict = await findConflictingWebhookPathOwner({ - path: effectivePath, + path: triggerPath, workflowId, }) if (pathConflict) { logger.warn( - `[${requestId}] Webhook path conflict for "${effectivePath}": already owned by workflow ${pathConflict}` + `[${requestId}] Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}` ) return { success: false, error: { - message: `Webhook path "${effectivePath}" is already in use. Choose a different path.`, + message: `Webhook path "${triggerPath}" is already in use. Choose a different path.`, status: 409, }, } } } - webhookConfigs.set(block.id, { - provider: effectiveProvider, - providerConfig, - triggerPath: effectivePath, - routingKey, - triggerDef, - }) + webhookConfigs.set(block.id, resolved.config) const existingForBlock = webhooksByBlockId.get(block.id) ?? [] if (existingForBlock.length === 0) { @@ -650,11 +755,11 @@ export async function saveTriggerWebhooksForDeploy({ const existingConfig = (existingWh.providerConfig as Record) || {} const needsRecreation = forceRecreateSubscriptions || - existingWh.provider !== effectiveProvider || + existingWh.provider !== provider || // Routing transitions (path-based <-> routing-key, or a changed key) // must recreate the row even when the provider config compares equal — // otherwise a stale delivery surface stays active on the old route. - (existingWh.path ?? null) !== effectivePath || + (existingWh.path ?? null) !== triggerPath || ((existingWh.routingKey as string | null) ?? null) !== routingKey || hasWebhookConfigChanged(existingConfig, providerConfig) diff --git a/apps/sim/lib/webhooks/path-claims.test.ts b/apps/sim/lib/webhooks/path-claims.test.ts new file mode 100644 index 00000000000..3ab2a0b1691 --- /dev/null +++ b/apps/sim/lib/webhooks/path-claims.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +type Condition = + | { kind: 'and'; conditions: Condition[] } + | { kind: 'eq'; column: string; value: unknown } + | { kind: 'lte'; column: string; value: unknown } + +vi.mock('drizzle-orm', () => ({ + and: (...conditions: Condition[]) => ({ kind: 'and', conditions }), + eq: (column: string, value: unknown) => ({ kind: 'eq', column, value }), + lte: (column: string, value: unknown) => ({ kind: 'lte', column, value }), +})) + +import type { DbOrTx } from '@sim/workflow-persistence/types' +import { + claimWebhookPath, + StaleWebhookPathClaimGenerationError, + WebhookPathClaimConflictError, +} from '@/lib/webhooks/path-claims' + +interface ClaimRow { + path: string + workflowId: string + generation: number +} + +function conditionValue(condition: Condition, column: string): unknown { + if (condition.kind === 'eq' && condition.column === column) return condition.value + if (condition.kind !== 'and') return undefined + for (const nested of condition.conditions) { + const value = conditionValue(nested, column) + if (value !== undefined) return value + } + return undefined +} + +function createClaimTx(claims: Map): DbOrTx { + return { + insert: () => ({ + values: (values: ClaimRow) => ({ + onConflictDoUpdate: () => ({ + returning: async () => { + const current = claims.get(values.path) + if ( + current && + (current.workflowId !== values.workflowId || current.generation > values.generation) + ) { + return [] + } + const claimed = { ...values } + claims.set(values.path, claimed) + return [claimed] + }, + }), + }), + }), + select: () => ({ + from: () => ({ + where: (condition: Condition) => ({ + limit: async () => { + const path = conditionValue(condition, 'path') as string + const current = claims.get(path) + return current ? [current] : [] + }, + }), + }), + }), + } as unknown as DbOrTx +} + +describe('webhook path claims', () => { + it('atomically gives a normalized path to one workflow under concurrent claims', async () => { + const claims = new Map() + const results = await Promise.allSettled([ + claimWebhookPath(createClaimTx(claims), { + path: ' /shared/path/ ', + workflowId: 'workflow-a', + generation: 1, + }), + claimWebhookPath(createClaimTx(claims), { + path: 'shared/path', + workflowId: 'workflow-b', + generation: 1, + }), + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const rejection = results.find((result) => result.status === 'rejected') + expect(rejection).toBeDefined() + if (rejection?.status !== 'rejected') throw new Error('Expected one rejected claim') + expect(rejection.reason).toBeInstanceOf(WebhookPathClaimConflictError) + expect(claims.get('shared/path')?.workflowId).toMatch(/^workflow-[ab]$/) + }) + + it('allows the same workflow to advance but rejects a stale generation', async () => { + const claims = new Map() + const tx = createClaimTx(claims) + await claimWebhookPath(tx, { path: 'events', workflowId: 'workflow-a', generation: 3 }) + await claimWebhookPath(tx, { path: '/events/', workflowId: 'workflow-a', generation: 4 }) + + await expect( + claimWebhookPath(tx, { path: 'events', workflowId: 'workflow-a', generation: 3 }) + ).rejects.toBeInstanceOf(StaleWebhookPathClaimGenerationError) + expect(claims.get('events')?.generation).toBe(4) + }) +}) diff --git a/apps/sim/lib/webhooks/path-claims.ts b/apps/sim/lib/webhooks/path-claims.ts new file mode 100644 index 00000000000..e88fd1b35de --- /dev/null +++ b/apps/sim/lib/webhooks/path-claims.ts @@ -0,0 +1,117 @@ +import { webhookPathClaim } from '@sim/db/schema' +import type { DbOrTx } from '@sim/workflow-persistence/types' +import { and, eq, lte } from 'drizzle-orm' +import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity' + +export class WebhookPathClaimConflictError extends Error { + readonly code = 'webhook_path_conflict' + + constructor( + readonly path: string, + readonly ownerWorkflowId: string + ) { + super(`Webhook path "${path}" is already owned by workflow ${ownerWorkflowId}`) + this.name = 'WebhookPathClaimConflictError' + } +} + +export class StaleWebhookPathClaimGenerationError extends Error { + readonly code = 'stale_webhook_path_claim_generation' + + constructor( + readonly path: string, + readonly attemptedGeneration: number, + readonly currentGeneration: number + ) { + super( + `Webhook path "${path}" is already fenced at generation ${currentGeneration}; generation ${attemptedGeneration} is stale` + ) + this.name = 'StaleWebhookPathClaimGenerationError' + } +} + +function normalizeClaimPath(path: string): string { + const normalizedPath = normalizeWebhookRegistrationPath(path) + if (!normalizedPath) { + throw new TypeError('Webhook path claim cannot be empty') + } + return normalizedPath +} + +function assertClaimGeneration(generation: number): void { + if (!Number.isSafeInteger(generation) || generation < 0) { + throw new TypeError('Webhook path claim generation must be a non-negative safe integer') + } +} + +/** + * Releases every path claim held by a workflow. + * + * Claims stay sticky through generation rotations, but once a workflow is + * explicitly undeployed or archived it no longer serves traffic, so other + * workflows must be able to adopt its paths. Runs inside the caller's + * undeploy/archive transaction. + */ +export async function releaseWebhookPathClaims(tx: DbOrTx, workflowId: string): Promise { + await tx.delete(webhookPathClaim).where(eq(webhookPathClaim.workflowId, workflowId)) +} + +/** + * Atomically acquires or advances ownership of a normalized webhook path. + * + * Ownership never transfers between workflows. The conflict update is generation-CAS guarded, + * so the primary-key write is the ownership decision rather than a preceding availability check. + */ +export async function claimWebhookPath( + tx: DbOrTx, + input: { path: string; workflowId: string; generation: number } +): Promise { + const path = normalizeClaimPath(input.path) + assertClaimGeneration(input.generation) + const now = new Date() + + const [claimed] = await tx + .insert(webhookPathClaim) + .values({ + path, + workflowId: input.workflowId, + generation: input.generation, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: webhookPathClaim.path, + set: { + generation: input.generation, + updatedAt: now, + }, + setWhere: and( + eq(webhookPathClaim.workflowId, input.workflowId), + lte(webhookPathClaim.generation, input.generation) + ), + }) + .returning({ + path: webhookPathClaim.path, + workflowId: webhookPathClaim.workflowId, + generation: webhookPathClaim.generation, + }) + + if (claimed) return claimed.path + + const [current] = await tx + .select({ + workflowId: webhookPathClaim.workflowId, + generation: webhookPathClaim.generation, + }) + .from(webhookPathClaim) + .where(eq(webhookPathClaim.path, path)) + .limit(1) + + if (!current) { + throw new Error(`Webhook path "${path}" could not be claimed`) + } + if (current.workflowId !== input.workflowId) { + throw new WebhookPathClaimConflictError(path, current.workflowId) + } + throw new StaleWebhookPathClaimGenerationError(path, input.generation, current.generation) +} diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 91f65015b69..efab00fc046 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -2,11 +2,13 @@ import { db } from '@sim/db' import { account, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import type { Logger } from '@sim/logger' import { and, eq, isNull, ne, or, sql } from 'drizzle-orm' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' import { getOAuthToken, refreshAccessTokenIfNeeded, resolveOAuthAccountId, + resolveServiceAccountToken, } from '@/app/api/auth/oauth/utils' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' @@ -80,8 +82,7 @@ export async function fetchActiveWebhooks( .where( and( eq(webhook.provider, provider), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), eq(workflow.isDeployed, true), isNull(workflow.archivedAt), or( @@ -203,6 +204,13 @@ export async function resolveOAuthCredential( `Failed to resolve OAuth account for credential ${credentialId}, webhook ${webhookData.id}` ) } + if (resolved.credentialType === 'service_account' && resolved.credentialId) { + const { accessToken: serviceAccountToken } = await resolveServiceAccountToken( + resolved.credentialId, + resolved.providerId + ) + return serviceAccountToken + } const rows = await db.select().from(account).where(eq(account.id, resolved.accountId)).limit(1) if (!rows.length) { throw new Error(`Credential ${credentialId} not found for webhook ${webhookData.id}`) diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index a2702d4aa20..92f0e9c6927 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -42,7 +42,10 @@ const { mockReleaseExecutionSlot: vi.fn(), mockProviderHandler: { current: {} as Record }, mockShouldExecuteInline: vi.fn(), - mockWebhookLookupResult: { rows: [] as WebhookLookupRow[] }, + mockWebhookLookupResult: { + rows: [] as WebhookLookupRow[], + claim: [] as Array<{ workflowId: string }>, + }, })) const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution @@ -52,11 +55,15 @@ vi.mock('@sim/db', () => { from: () => selectChain, innerJoin: () => selectChain, leftJoin: () => selectChain, - where: () => Promise.resolve(mockWebhookLookupResult.rows), + where: () => ({ + then: (resolve: (rows: WebhookLookupRow[]) => void) => resolve(mockWebhookLookupResult.rows), + limit: () => Promise.resolve(mockWebhookLookupResult.claim), + }), } return { db: { select: () => selectChain }, webhook: {}, + webhookPathClaim: {}, workflow: {}, workflowDeploymentVersion: {}, } @@ -222,6 +229,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { beforeEach(() => { vi.clearAllMocks() mockWebhookLookupResult.rows = [] + mockWebhookLookupResult.claim = [] }) const makeRow = (workflowId: string, webhookId: string, createdAt: Date) => ({ @@ -253,6 +261,30 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { expect(results[0].webhook.workflowId).toBe('victim-workflow') }) + it('prefers the path-claim owner over an earlier-created interloper', async () => { + const interloper = makeRow('interloper-workflow', 'interloper-wh', new Date('2026-01-01')) + const claimHolder = makeRow('claim-workflow', 'claim-wh', new Date('2026-05-01')) + mockWebhookLookupResult.rows = [interloper, claimHolder] + mockWebhookLookupResult.claim = [{ workflowId: 'claim-workflow' }] + + const results = await findAllWebhooksForPath({ requestId: 'req-6', path: 'shared-path' }) + + expect(results).toHaveLength(1) + expect(results[0].webhook.workflowId).toBe('claim-workflow') + }) + + it('falls back to earliest registration when the claim owner has no deliverable rows', async () => { + const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01')) + const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01')) + mockWebhookLookupResult.rows = [attacker, victim] + mockWebhookLookupResult.claim = [{ workflowId: 'absent-workflow' }] + + const results = await findAllWebhooksForPath({ requestId: 'req-7', path: 'shared-path' }) + + expect(results).toHaveLength(1) + expect(results[0].webhook.workflowId).toBe('victim-workflow') + }) + it("preserves the owner's full multi-webhook match while dropping a foreign row", async () => { const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01')) const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03')) @@ -436,6 +468,7 @@ describe('webhook processor execution identity', () => { makeWebhookRecord({ path: 'incoming/gmail', provider: 'gmail', + deploymentVersionId: 'deployment-admitted', }), makeWorkflowRecord({}), { event: 'message.received' }, @@ -453,6 +486,7 @@ describe('webhook processor execution identity', () => { expect.objectContaining({ workflowId: 'workflow-1', provider: 'gmail', + deploymentVersionId: 'deployment-admitted', }), expect.objectContaining({ metadata: expect.objectContaining({ diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index d9fb2c82aa9..dff289f8aaa 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1,4 +1,4 @@ -import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, webhook, webhookPathClaim, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -23,6 +23,7 @@ import { import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { getPendingWebhookVerification, matchesPendingWebhookVerificationProbe, @@ -30,6 +31,7 @@ import { } from '@/lib/webhooks/pending-verification' import { getProviderHandler } from '@/lib/webhooks/providers' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' import { SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants' import { executeWebhookJob, type WebhookExecutionPayload } from '@/background/webhook-execution' @@ -316,8 +318,7 @@ async function findWebhookAndWorkflow( .where( and( eq(webhook.id, options.webhookId), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -353,8 +354,7 @@ async function findWebhookAndWorkflow( .where( and( eq(webhook.path, options.path), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -380,8 +380,9 @@ async function findWebhookAndWorkflow( * * Legitimate multi-webhook matches are always within one workflow, but paths * are user-controlled and only unique per deployment version, so two tenants can - * register the same path. On collision we keep only the workflow that registered - * the path first, so one tenant can never receive another's webhook deliveries. + * register the same path. On collision the `webhook_path_claim` owner wins; + * without a claim we keep the workflow that registered the path first, so one + * tenant can never receive another's webhook deliveries. */ export async function findAllWebhooksForPath( options: WebhookProcessorOptions @@ -407,8 +408,7 @@ export async function findAllWebhooksForPath( .where( and( eq(webhook.path, options.path), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -425,19 +425,23 @@ export async function findAllWebhooksForPath( const distinctWorkflowIds = new Set(results.map((result) => result.webhook.workflowId)) if (distinctWorkflowIds.size > 1) { - const owner = results.reduce((earliest, candidate) => { - const candidateTime = new Date(candidate.webhook.createdAt).getTime() - const earliestTime = new Date(earliest.webhook.createdAt).getTime() - if (candidateTime !== earliestTime) { - return candidateTime < earliestTime ? candidate : earliest - } - return candidate.webhook.id < earliest.webhook.id ? candidate : earliest - }) + const claimOwnerWorkflowId = await findWebhookPathClaimOwner(options.path) + const owner = + (claimOwnerWorkflowId && + results.find((result) => result.webhook.workflowId === claimOwnerWorkflowId)) || + results.reduce((earliest, candidate) => { + const candidateTime = new Date(candidate.webhook.createdAt).getTime() + const earliestTime = new Date(earliest.webhook.createdAt).getTime() + if (candidateTime !== earliestTime) { + return candidateTime < earliestTime ? candidate : earliest + } + return candidate.webhook.id < earliest.webhook.id ? candidate : earliest + }) const ownerWorkflowId = owner.webhook.workflowId const ownerResults = results.filter((result) => result.webhook.workflowId === ownerWorkflowId) logger.error( - `[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} and dropping ${results.length - ownerResults.length} foreign webhook(s).` + `[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} (${claimOwnerWorkflowId === ownerWorkflowId ? 'path-claim owner' : 'earliest registration'}) and dropping ${results.length - ownerResults.length} foreign webhook(s).` ) return ownerResults @@ -450,6 +454,22 @@ export async function findAllWebhooksForPath( return results } +/** + * Resolves the sticky `webhook_path_claim` owner for a delivery path, so + * collision resolution can prefer the workflow that legitimately claimed the + * path over an interloper that registered a row first. + */ +async function findWebhookPathClaimOwner(path: string): Promise { + const normalizedPath = normalizeWebhookRegistrationPath(path) + if (!normalizedPath) return null + const [claim] = await db + .select({ workflowId: webhookPathClaim.workflowId }) + .from(webhookPathClaim) + .where(eq(webhookPathClaim.path, normalizedPath)) + .limit(1) + return claim?.workflowId ?? null +} + /** * Finds all active `slack_app` webhooks for a Slack `team_id` (the routing key). * @@ -486,8 +506,7 @@ export async function findWebhooksByRoutingKey( and( eq(webhook.routingKey, routingKey), eq(webhook.provider, provider), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -752,6 +771,9 @@ async function queueWebhookExecutionWithResult( headers, path: options.path || foundWebhook.path || '', blockId: foundWebhook.blockId ?? undefined, + ...(foundWebhook.deploymentVersionId + ? { deploymentVersionId: foundWebhook.deploymentVersionId } + : {}), workspaceId, ...(credentialId ? { credentialId } : {}), ...(options.receivedAt !== undefined ? { webhookReceivedAt: options.receivedAt } : {}), @@ -1063,6 +1085,9 @@ export async function processPolledWebhookEvent( headers: { 'content-type': 'application/json' } as Record, path: foundWebhook.path ?? '', blockId: foundWebhook.blockId ?? undefined, + ...(foundWebhook.deploymentVersionId + ? { deploymentVersionId: foundWebhook.deploymentVersionId } + : {}), workspaceId, ...(credentialId ? { credentialId } : {}), } satisfies WebhookExecutionPayload diff --git a/apps/sim/lib/webhooks/provider-subscriptions.test.ts b/apps/sim/lib/webhooks/provider-subscriptions.test.ts index beef3d4a62d..336dc8ed3da 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.test.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.test.ts @@ -17,7 +17,10 @@ vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: mockGetProviderHandler, })) -import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions' +import { + cleanupExternalWebhook, + createExternalWebhookSubscription, +} from '@/lib/webhooks/provider-subscriptions' describe('createExternalWebhookSubscription', () => { beforeEach(() => { @@ -119,3 +122,44 @@ describe('createExternalWebhookSubscription', () => { expect(result.updatedProviderConfig.token).toBe('{{SLACK_TOKEN}}') }) }) + +describe('cleanupExternalWebhook', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ CALENDLY_API_KEY: 'real-secret-key' }) + }) + + it('resolves {{ENV_VAR}} references before deleting the provider subscription', async () => { + const deleteSubscription = vi.fn().mockResolvedValue(undefined) + mockGetProviderHandler.mockReturnValue({ deleteSubscription }) + + const webhook = { + id: 'webhook-1', + provider: 'calendly', + providerConfig: { + apiKey: '{{CALENDLY_API_KEY}}', + externalId: 'external-1', + }, + } + const workflow = { + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + } + + await cleanupExternalWebhook(webhook, workflow, 'request-1') + + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'workspace-1') + expect(deleteSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + webhook: expect.objectContaining({ + providerConfig: { + apiKey: 'real-secret-key', + externalId: 'external-1', + }, + }), + }) + ) + expect(webhook.providerConfig.apiKey).toBe('{{CALENDLY_API_KEY}}') + }) +}) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index afd9a926648..35cad91fafa 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import type { NextRequest } from 'next/server' -import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver' +import { + resolveWebhookProviderConfig, + resolveWebhookRecordProviderConfig, +} from '@/lib/webhooks/env-resolver' import { getProviderHandler } from '@/lib/webhooks/providers' const logger = createLogger('WebhookProviderSubscriptions') @@ -29,10 +33,23 @@ const SYSTEM_MANAGED_FIELDS = new Set([ 'secretToken', 'historyId', 'lastCheckedTimestamp', + 'lastSeenGuids', 'setupCompleted', + 'subscriptionExpiration', 'userId', ]) +/** + * Returns the user-controlled projection used for stable registration identity. + * + * Provider-managed subscription metadata and mutable polling cursors are intentionally omitted. + */ +export function projectDesiredWebhookProviderConfig( + providerConfig: Readonly> +): Record { + return omit(providerConfig, [...SYSTEM_MANAGED_FIELDS]) +} + /** Returns true when user-controlled persisted webhook configuration changed. */ export function hasWebhookConfigChanged( previousConfig: Record, @@ -105,7 +122,8 @@ export async function createExternalWebhookSubscription( webhookData: Record, workflow: Record, userId: string, - requestId: string + requestId: string, + options: { signal?: AbortSignal } = {} ): Promise { const provider = webhookData.provider as string const providerConfig = (webhookData.providerConfig as Record) || {} @@ -123,6 +141,13 @@ export async function createExternalWebhookSubscription( workspaceId ) + /** + * Last abort check before the irreversible external call: a lease-expired + * outbox handler must not mint a provider resource it can no longer + * durably record. + */ + options.signal?.throwIfAborted() + const result = await handler.createSubscription({ webhook: { ...webhookData, providerConfig: resolvedProviderConfig }, workflow, @@ -143,6 +168,9 @@ export async function createExternalWebhookSubscription( /** * Clean up external webhook subscriptions for a webhook. + * Resolves persisted `{{ENV_VAR}}` references with the workflow owner's + * effective environment before invoking the provider. + * * By default, cleanup failure is logged but non-fatal for legacy best-effort callers. * Deployment outbox cleanup passes `throwOnError` so provider failures stay retryable. */ @@ -160,7 +188,23 @@ export async function cleanupExternalWebhook( } try { - await handler.deleteSubscription({ webhook, workflow, requestId, strict: options.throwOnError }) + if (typeof workflow.userId !== 'string') { + throw new Error('Cannot resolve webhook credentials without a workflow owner') + } + + const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined + const resolvedWebhook = await resolveWebhookRecordProviderConfig( + webhook, + workflow.userId, + workspaceId + ) + + await handler.deleteSubscription({ + webhook: resolvedWebhook, + workflow, + requestId, + strict: options.throwOnError, + }) } catch (error) { logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, { provider, diff --git a/apps/sim/lib/webhooks/providers/clickup.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts new file mode 100644 index 00000000000..00247cd12e4 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -0,0 +1,389 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { NextRequest, NextResponse } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetCredentialOwner, mockRefreshAccessTokenIfNeeded } = vi.hoisted(() => ({ + mockGetCredentialOwner: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), +})) + +vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ + getProviderConfig: (webhook: { providerConfig?: Record }) => + webhook.providerConfig || {}, + getNotificationUrl: () => 'https://app.example.com/api/webhooks/trigger/clickup-path', + getCredentialOwner: mockGetCredentialOwner, +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) + +import { clickupHandler } from '@/lib/webhooks/providers/clickup' + +const fetchMock = vi.fn() + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +function jsonResponse(status: number, body: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createContext(providerConfig: Record) { + return { + webhook: { id: 'webhook-row-1', path: 'clickup-path', providerConfig }, + workflow: {}, + userId: 'user-1', + requestId: 'req-1', + } as never +} + +describe('ClickUp webhook provider', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + mockGetCredentialOwner.mockResolvedValue({ userId: 'user-1', accountId: 'account-1' }) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('oauth-token') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('verifyAuth', () => { + it('fails closed when no webhookSecret is configured', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects when the X-Signature header is missing', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects an invalid signature', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Signature': 'deadbeef' }), + rawBody: '{"event":"taskCreated"}', + requestId: 't3', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('accepts a valid HMAC-SHA256 hex signature over the raw body', async () => { + const rawBody = '{"event":"taskCreated","task_id":"abc"}' + const signature = hmacSha256Hex(rawBody, 'secret') + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Signature': signature }), + rawBody, + requestId: 't4', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('matchEvent', () => { + it('passes when the event matches the trigger', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'taskCreated' }, + request: reqWithHeaders({}), + requestId: 't5', + providerConfig: { triggerId: 'clickup_task_created' }, + }) + expect(result).toBe(true) + }) + + it('skips with a response when the event does not match', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'taskDeleted' }, + request: reqWithHeaders({}), + requestId: 't6', + providerConfig: { triggerId: 'clickup_task_created' }, + }) + expect(result).toBeInstanceOf(NextResponse) + }) + + it('passes all events through for the catch-all trigger', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'goalCreated' }, + request: reqWithHeaders({}), + requestId: 't7', + providerConfig: { triggerId: 'clickup_webhook' }, + }) + expect(result).toBe(true) + }) + }) + + describe('extractIdempotencyId', () => { + it('derives the documented webhook_id:history_item_id key', () => { + const body = { + event: 'taskCreated', + webhook_id: 'wh-1', + task_id: 'abc', + history_items: [{ id: 'hist-1' }], + } + expect(clickupHandler.extractIdempotencyId!(body)).toBe('clickup:wh-1:hist-1') + expect(clickupHandler.extractIdempotencyId!({ ...body })).toBe('clickup:wh-1:hist-1') + }) + + it('returns null without history items or webhook_id', () => { + expect( + clickupHandler.extractIdempotencyId!({ event: 'taskCreated', webhook_id: 'wh-1' }) + ).toBeNull() + expect( + clickupHandler.extractIdempotencyId!({ + event: 'taskCreated', + history_items: [{ id: 'h1' }], + }) + ).toBeNull() + }) + }) + + describe('formatInput', () => { + const baseBody = { + event: 'taskCreated', + webhook_id: 'wh-1', + task_id: 'abc', + history_items: [{ id: 'h1' }], + } + + function formatCtx(triggerId: string, body: Record) { + return { + webhook: { providerConfig: { triggerId } }, + workflow: { id: 'wf1', userId: 'user-1' }, + body, + headers: {}, + requestId: 't8', + } as never + } + + it('maps task events to the task output keys', async () => { + const { input } = await clickupHandler.formatInput!( + formatCtx('clickup_task_created', baseBody) + ) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'historyItems', + 'payload', + 'taskId', + ]) + expect((input as Record).taskId).toBe('abc') + }) + + it('maps list events to the list output keys', async () => { + const body = { event: 'listCreated', webhook_id: 'wh-1', list_id: '162641285' } + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_list_created', body)) + expect((input as Record).listId).toBe('162641285') + expect((input as Record).eventType).toBe('listCreated') + }) + + it('maps goal events to the documented base keys only', async () => { + const body = { event: 'goalCreated', webhook_id: 'wh-1' } + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_goal_created', body)) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'historyItems', + 'payload', + ]) + }) + + it('maps the catch-all trigger to the generic output keys', async () => { + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_webhook', baseBody)) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'folderId', + 'historyItems', + 'listId', + 'payload', + 'spaceId', + 'taskId', + ]) + }) + }) + + describe('createSubscription', () => { + const validConfig = { + triggerId: 'clickup_task_created', + credentialId: 'cred-1', + triggerWorkspaceId: '108', + } + + it('creates the webhook and returns externalId + webhookSecret', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { id: 'ext-1', webhook: { id: 'ext-1', secret: 'shh' } }) + ) + + const result = await clickupHandler.createSubscription!(createContext(validConfig)) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.clickup.com/api/v2/team/108/webhook') + expect(init.headers.Authorization).toBe('Bearer oauth-token') + expect(JSON.parse(init.body)).toEqual({ + endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path', + events: ['taskCreated'], + }) + expect(result?.providerConfigUpdates).toEqual({ externalId: 'ext-1', webhookSecret: 'shh' }) + }) + + it('subscribes with the wildcard for the catch-all trigger and coerces location filters', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { id: 'ext-2', webhook: { secret: 'shh' } }) + ) + + await clickupHandler.createSubscription!( + createContext({ + triggerId: 'clickup_webhook', + credentialId: 'cred-1', + triggerWorkspaceId: '108', + triggerSpaceId: '1234', + triggerListId: '9876', + triggerTaskId: 'abc1234', + }) + ) + + const [, init] = fetchMock.mock.calls[0] + expect(JSON.parse(init.body)).toEqual({ + endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path', + events: ['*'], + space_id: 1234, + list_id: 9876, + task_id: 'abc1234', + }) + }) + + it('throws a friendly error when the workspace is missing', async () => { + await expect( + clickupHandler.createSubscription!( + createContext({ triggerId: 'clickup_task_created', credentialId: 'cred-1' }) + ) + ).rejects.toThrow(/workspace is required/i) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('throws a friendly error on 401 from ClickUp', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { err: 'Token invalid' })) + await expect(clickupHandler.createSubscription!(createContext(validConfig))).rejects.toThrow( + /authentication failed/i + ) + }) + + it('rolls back the created webhook and throws when no secret is returned', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'ext-3', webhook: { id: 'ext-3' } })) + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + + await expect(clickupHandler.createSubscription!(createContext(validConfig))).rejects.toThrow( + /no signing secret/i + ) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] + expect(deleteUrl).toBe('https://api.clickup.com/api/v2/webhook/ext-3') + expect(deleteInit.method).toBe('DELETE') + }) + + it('rejects non-integer location filters before calling ClickUp', async () => { + await expect( + clickupHandler.createSubscription!( + createContext({ ...validConfig, triggerSpaceId: 'not-a-number' }) + ) + ).rejects.toThrow(/Space ID must be a whole number/) + await expect( + clickupHandler.createSubscription!( + createContext({ ...validConfig, triggerSpaceId: '12.5' }) + ) + ).rejects.toThrow(/Space ID must be a whole number/) + expect(fetchMock).not.toHaveBeenCalled() + }) + }) + + describe('deleteSubscription', () => { + it('deletes the external webhook', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.clickup.com/api/v2/webhook/ext-1') + expect(init.method).toBe('DELETE') + }) + + it('tolerates 404 and never throws when not strict', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(404, {})) + await expect( + clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + }) + ).resolves.toBeUndefined() + }) + + it('throws on failure only when strict', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(500, {})) + await expect( + clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + strict: true, + }) + ).rejects.toThrow(/Failed to delete ClickUp webhook/) + }) + + it('skips gracefully when externalId is missing', async () => { + await expect( + clickupHandler.deleteSubscription!({ + webhook: { id: 'webhook-row-1', providerConfig: { credentialId: 'cred-1' } }, + workflow: {}, + requestId: 'req-1', + }) + ).resolves.toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts new file mode 100644 index 00000000000..63fa5c420ac --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -0,0 +1,377 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { toError } from '@sim/utils/errors' +import { NextResponse } from 'next/server' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('WebhookProvider:ClickUp') + +function validateClickUpSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + return false + } + const computedHash = hmacSha256Hex(body, secret) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating ClickUp signature:', error) + return false + } +} + +/** + * Resolves the OAuth access token for the credential stored in the webhook's + * provider config. Throws a user-facing error when the credential is missing + * or the token cannot be retrieved. + */ +async function resolveClickUpAccessToken( + credentialId: string | undefined, + requestId: string +): Promise { + if (!credentialId) { + throw new Error( + 'ClickUp account connection required. Please connect your ClickUp account in the trigger configuration and try again.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) + : null + + if (!accessToken) { + throw new Error( + 'ClickUp account connection required. Please connect your ClickUp account in the trigger configuration and try again.' + ) + } + + return accessToken +} + +/** + * Parses an optional numeric location filter (space, folder, list). ClickUp + * expects these as integers in the create-webhook body. + */ +function parseOptionalNumericId(value: unknown, label: string): number | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const raw = String(value).trim() + if (!raw) return undefined + const parsed = Number(raw) + if (!Number.isInteger(parsed)) { + throw new Error(`ClickUp ${label} must be a whole number. Received: ${raw}`) + } + return parsed +} + +function parseOptionalStringId(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const raw = String(value).trim() + return raw || undefined +} + +async function deleteClickUpWebhook(accessToken: string, externalId: string): Promise { + return fetch(`${CLICKUP_API_BASE_URL}/webhook/${externalId}`, { + method: 'DELETE', + headers: { Authorization: clickupAuthorizationHeader(accessToken) }, + }) +} + +export const clickupHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Signature', + validateFn: validateClickUpSignature, + providerLabel: 'ClickUp', + requireSecret: true, + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'clickup_webhook') { + const { isClickUpEventMatch, getClickUpEventType } = await import('@/triggers/clickup/utils') + if (!isClickUpEventMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] ClickUp event mismatch for trigger ${triggerId}. Event: ${getClickUpEventType(obj)}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + + return true + }, + + /** + * ClickUp documents `{{webhook_id}}:{{history_item_id}}` as the recommended + * idempotency key; history item IDs are unique per delivered event. + */ + extractIdempotencyId(body: unknown) { + const obj = body as Record + const webhookId = obj.webhook_id + if (typeof webhookId !== 'string' || !webhookId) return null + + const historyItems = Array.isArray(obj.history_items) ? obj.history_items : [] + const firstItem = historyItems[0] as Record | undefined + const historyId = firstItem?.id + if (!historyId) return null + + return `clickup:${webhookId}:${historyId}` + }, + + async createSubscription({ + webhook: webhookRecord, + requestId, + }: SubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const triggerId = config.triggerId as string | undefined + const credentialId = config.credentialId as string | undefined + + const accessToken = await resolveClickUpAccessToken(credentialId, requestId) + + const workspaceId = parseOptionalStringId(config.triggerWorkspaceId) + if (!workspaceId) { + throw new Error( + 'ClickUp workspace is required. Please select a workspace in the trigger configuration and try again.' + ) + } + + let events: string[] + if (triggerId === 'clickup_webhook') { + events = ['*'] + } else { + const { CLICKUP_TRIGGER_EVENT_MAP } = await import('@/triggers/clickup/utils') + const mappedEvents = CLICKUP_TRIGGER_EVENT_MAP[triggerId as string] + if (!mappedEvents || mappedEvents.length === 0) { + throw new Error(`Unknown ClickUp trigger type: ${triggerId}`) + } + events = mappedEvents + } + + const requestBody: Record = { + endpoint: getNotificationUrl(webhookRecord), + events, + } + + const spaceId = parseOptionalNumericId(config.triggerSpaceId, 'Space ID') + const folderId = parseOptionalNumericId(config.triggerFolderId, 'Folder ID') + const listId = parseOptionalNumericId(config.triggerListId, 'List ID') + const taskId = parseOptionalStringId(config.triggerTaskId) + if (spaceId !== undefined) requestBody.space_id = spaceId + if (folderId !== undefined) requestBody.folder_id = folderId + if (listId !== undefined) requestBody.list_id = listId + if (taskId !== undefined) requestBody.task_id = taskId + + const clickupResponse = await fetch( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(workspaceId)}/webhook`, + { + method: 'POST', + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } + ) + + if (!clickupResponse.ok) { + const errorBody = (await clickupResponse.json().catch(() => ({}))) as { err?: string } + logger.error( + `[${requestId}] Failed to create webhook in ClickUp for webhook ${webhookRecord.id}. Status: ${clickupResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in ClickUp' + if (clickupResponse.status === 401) { + userFriendlyMessage = + 'ClickUp authentication failed. Please reconnect your ClickUp account.' + } else if (clickupResponse.status === 403) { + userFriendlyMessage = + 'ClickUp access denied. Please ensure your account can manage webhooks in this workspace.' + } else if (errorBody.err) { + userFriendlyMessage = `Failed to create webhook subscription in ClickUp: ${errorBody.err}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = (await clickupResponse.json().catch(() => ({}))) as { + id?: string + webhook?: { id?: string; secret?: string } + } + const externalId = responseBody.id ?? responseBody.webhook?.id + const secret = responseBody.webhook?.secret + const redactedResponse = { + ...responseBody, + webhook: responseBody.webhook + ? { + ...responseBody.webhook, + secret: responseBody.webhook.secret ? '[redacted]' : undefined, + } + : undefined, + } + + if (!externalId) { + logger.error( + `[${requestId}] ClickUp webhook created but no webhook id returned for webhook ${webhookRecord.id}`, + { response: redactedResponse } + ) + throw new Error('ClickUp webhook creation succeeded but no webhook ID was returned') + } + + if (!secret) { + logger.error( + `[${requestId}] ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, + { response: redactedResponse } + ) + const rollback = await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) + if (!rollback || (!rollback.ok && rollback.status !== 404)) { + logger.error( + `[${requestId}] Failed to roll back ClickUp webhook ${externalId} after missing secret`, + { clickupWebhookId: externalId, status: rollback?.status } + ) + throw new Error( + `ClickUp webhook creation succeeded but no signing secret was returned, and rolling back the webhook failed. Delete webhook ${externalId} manually in ClickUp before retrying.` + ) + } + throw new Error('ClickUp webhook creation succeeded but no signing secret was returned') + } + + logger.info( + `[${requestId}] Successfully created webhook in ClickUp for webhook ${webhookRecord.id}.`, + { + clickupWebhookId: externalId, + workspaceId, + events, + } + ) + + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + } catch (error: unknown) { + const message = toError(error).message + logger.error( + `[${requestId}] Exception during ClickUp webhook creation for webhook ${webhookRecord.id}.`, + { message } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + requestId, + strict, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const externalId = config.externalId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + if (strict) throw new Error('Missing ClickUp externalId for webhook deletion') + return + } + + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + if (strict) throw new Error('Missing ClickUp credentialId for webhook deletion') + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + const message = `[${requestId}] Could not retrieve ClickUp access token. Cannot delete webhook.` + logger.warn(message, { webhookId: webhookRecord.id }) + if (strict) throw new Error(message) + return + } + + const clickupResponse = await deleteClickUpWebhook(accessToken, externalId) + + if (!clickupResponse.ok && clickupResponse.status !== 404) { + const responseBody = await clickupResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete ClickUp webhook (non-fatal): ${clickupResponse.status}`, + { response: responseBody } + ) + if (strict) throw new Error(`Failed to delete ClickUp webhook: ${clickupResponse.status}`) + } else { + logger.info(`[${requestId}] Successfully deleted ClickUp webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting ClickUp webhook (non-fatal)`, error) + if (strict) throw error + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractClickUpTaskData, + extractClickUpListData, + extractClickUpFolderData, + extractClickUpSpaceData, + extractClickUpGoalData, + extractClickUpGenericData, + } = await import('@/triggers/clickup/utils') + + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId?.startsWith('clickup_task_')) { + return { input: extractClickUpTaskData(b) } + } + if (triggerId?.startsWith('clickup_list_')) { + return { input: extractClickUpListData(b) } + } + if (triggerId?.startsWith('clickup_folder_')) { + return { input: extractClickUpFolderData(b) } + } + if (triggerId?.startsWith('clickup_space_')) { + return { input: extractClickUpSpaceData(b) } + } + if (triggerId?.startsWith('clickup_goal_') || triggerId?.startsWith('clickup_key_result_')) { + return { input: extractClickUpGoalData(b) } + } + return { input: extractClickUpGenericData(b) } + }, +} diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts index 650ebfb0980..ca5e1094bb4 100644 --- a/apps/sim/lib/webhooks/providers/gmail.ts +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -21,7 +21,11 @@ export const gmailHandler: WebhookProviderHandler = { return { input: b } }, - async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + async configurePolling({ + webhook: webhookData, + requestId, + persistProviderConfig, + }: PollingConfigContext) { logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) try { @@ -79,26 +83,27 @@ export const gmailHandler: WebhookProviderHandler = { const now = new Date() - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: - (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id as string)) + const configuredProviderConfig = { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + } + if (persistProviderConfig) { + await persistProviderConfig(configuredProviderConfig) + } else { + await db + .update(webhook) + .set({ providerConfig: configuredProviderConfig, updatedAt: now }) + .where(eq(webhook.id, webhookData.id as string)) + } logger.info( `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` diff --git a/apps/sim/lib/webhooks/providers/grain.test.ts b/apps/sim/lib/webhooks/providers/grain.test.ts new file mode 100644 index 00000000000..b4b2d0336f6 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/grain.test.ts @@ -0,0 +1,236 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { grainHandler } from '@/lib/webhooks/providers/grain' + +const WEBHOOK_ID = 'webhook-uuid-1234' + +const fetchMock = vi.fn() + +function makeWebhook(providerConfig: Record) { + return { + id: WEBHOOK_ID, + path: 'grain-path', + providerConfig, + } as unknown as Parameters[0]['webhook'] +} + +function jsonResponse(status: number, body: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createContext(providerConfig: Record) { + return { + webhook: makeWebhook(providerConfig), + workflow: {}, + userId: 'user-1', + requestId: 'req-1', + } as never +} + +describe('grainHandler createSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com' + }) + + afterEach(() => { + vi.unstubAllGlobals() + process.env.NEXT_PUBLIC_APP_URL = undefined + }) + + it('creates the hook for a single-event v2 trigger', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'hook-1' })) + + const result = await grainHandler.createSubscription!( + createContext({ + apiKey: 'grain-key', + triggerId: 'grain_recording_added_v2', + }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.grain.com/_/public-api/v2/hooks/create') + expect(init.headers['Public-Api-Version']).toBe('2025-10-31') + expect(JSON.parse(init.body)).toMatchObject({ hook_type: 'recording_added' }) + + expect(result?.providerConfigUpdates).toMatchObject({ + externalIds: ['hook-1'], + externalId: 'hook-1', + eventTypes: ['recording_added'], + }) + }) + + it('creates one hook per event type for the All Events trigger', async () => { + for (let i = 1; i <= 10; i++) { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: `hook-${i}` })) + } + + const result = await grainHandler.createSubscription!( + createContext({ + apiKey: 'grain-key', + triggerId: 'grain_all_events_v2', + }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(10) + const hookTypes = fetchMock.mock.calls.map((call) => JSON.parse(call[1].body).hook_type) + expect(hookTypes).toEqual([ + 'recording_added', + 'recording_updated', + 'recording_deleted', + 'highlight_added', + 'highlight_updated', + 'highlight_deleted', + 'story_added', + 'story_updated', + 'story_deleted', + 'upload_status', + ]) + expect(result?.providerConfigUpdates).toMatchObject({ + externalIds: hookTypes.map((_, i) => `hook-${i + 1}`), + externalId: 'hook-1', + }) + }) + + it('keeps legacy view-scoped triggers on the v1 API without remapping', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'legacy-hook-1' })) + + const result = await grainHandler.createSubscription!( + createContext({ + apiKey: 'grain-key', + triggerId: 'grain_recording_created', + viewId: 'legacy-view', + }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.grain.com/_/public-api/hooks') + expect(init.headers['Public-Api-Version']).toBeUndefined() + expect(JSON.parse(init.body)).toMatchObject({ + version: 2, + view_id: 'legacy-view', + actions: ['added'], + }) + expect(result?.providerConfigUpdates).toEqual({ + externalId: 'legacy-hook-1', + eventTypes: ['recording_added'], + }) + }) + + it('still requires a view id for legacy triggers', async () => { + await expect( + grainHandler.createSubscription!( + createContext({ apiKey: 'grain-key', triggerId: 'grain_recording_created' }) + ) + ).rejects.toThrow('Grain view ID is required') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rolls back already-created hooks when a later create fails', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { id: 'hook-1' })) + .mockResolvedValueOnce(jsonResponse(400, { error: 'bad_request' })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })) + + await expect( + grainHandler.createSubscription!( + createContext({ + apiKey: 'grain-key', + triggerId: 'grain_all_events_v2', + }) + ) + ).rejects.toThrow('Grain error: bad_request') + + const deleteCall = fetchMock.mock.calls[2] + expect(deleteCall[0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-1') + expect(deleteCall[1].method).toBe('DELETE') + }) + + it('rejects when the api key is missing', async () => { + await expect( + grainHandler.createSubscription!(createContext({ triggerId: 'grain_recording_added_v2' })) + ).rejects.toThrow('Grain API Key is required') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('grainHandler deleteSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('deletes every hook recorded in externalIds', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { success: true })) + .mockResolvedValueOnce(jsonResponse(404, {})) + + await grainHandler.deleteSubscription!({ + webhook: makeWebhook({ + apiKey: 'grain-key', + externalIds: ['hook-1', 'hook-2'], + externalId: 'hook-1', + }), + workflow: {}, + requestId: 'req-1', + strict: true, + } as never) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-1') + expect(fetchMock.mock.calls[1][0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-2') + }) + + it('deletes legacy single-externalId rows through the v1 endpoint', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true })) + + await grainHandler.deleteSubscription!({ + webhook: makeWebhook({ apiKey: 'grain-key', externalId: 'legacy-hook' }), + workflow: {}, + requestId: 'req-1', + strict: true, + } as never) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.grain.com/_/public-api/hooks/legacy-hook') + expect(init.headers['Public-Api-Version']).toBeUndefined() + }) + + it('throws in strict mode when a delete fails with a server error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(500, {})) + + await expect( + grainHandler.deleteSubscription!({ + webhook: makeWebhook({ apiKey: 'grain-key', externalIds: ['hook-1'] }), + workflow: {}, + requestId: 'req-1', + strict: true, + } as never) + ).rejects.toThrow('Failed to delete 1 Grain webhook(s)') + }) + + it('is a strict no-op failure when cleanup config is missing', async () => { + await expect( + grainHandler.deleteSubscription!({ + webhook: makeWebhook({ apiKey: 'grain-key' }), + workflow: {}, + requestId: 'req-1', + strict: true, + } as never) + ).rejects.toThrow('Missing Grain externalId for webhook deletion') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts index 8b32fb17db9..4270b6babe5 100644 --- a/apps/sim/lib/webhooks/providers/grain.ts +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -11,9 +11,245 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { skipByEventTypes } from '@/lib/webhooks/providers/utils' +import { GRAIN_V2_TRIGGER_TO_HOOK_TYPES } from '@/triggers/grain/utils' const logger = createLogger('WebhookProvider:Grain') +const GRAIN_V2_HOOKS_BASE = 'https://api.grain.com/_/public-api/v2/hooks' +const GRAIN_API_VERSION = '2025-10-31' + +function grainErrorMessage(responseBody: Record): string { + const errors = responseBody.errors as Record | undefined + const error = responseBody.error as Record | string | undefined + return ( + errors?.detail || + (typeof error === 'object' ? error?.message : undefined) || + (typeof error === 'string' ? error : undefined) || + (responseBody.message as string) || + 'Unknown Grain API error' + ) +} + +function grainUserFacingError(status: number, errorMessage: string): string { + if (status === 401) { + return 'Invalid Grain API Key. Please verify your access token is correct.' + } + if (status === 403) { + return 'Access denied. Please ensure your Grain API Key has appropriate permissions.' + } + if (errorMessage && errorMessage !== 'Unknown Grain API error') { + return `Grain error: ${errorMessage}` + } + return 'Failed to create webhook subscription in Grain' +} + +/** + * Creates one Grain v2 hook per requested hook type. The v2 API has no + * multi-event hooks, so a trigger subscribing to several event types owns + * several external hooks — their ids are all recorded in `externalIds`. + * Hooks already created on a previous partial attempt are deleted before + * rethrowing so a failed prepare never leaks subscriptions. + */ +async function createGrainV2Hooks(params: { + apiKey: string + notificationUrl: string + hookTypes: string[] + requestId: string + webhookId: string +}): Promise { + const { apiKey, notificationUrl, hookTypes, requestId, webhookId } = params + const createdIds: string[] = [] + + try { + for (const hookType of hookTypes) { + const response = await fetch(`${GRAIN_V2_HOOKS_BASE}/create`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Public-Api-Version': GRAIN_API_VERSION, + }, + body: JSON.stringify({ hook_url: notificationUrl, hook_type: hookType }), + }) + const responseBody = (await response.json().catch(() => ({}))) as Record + + if (!response.ok || responseBody.error || responseBody.errors) { + const message = grainErrorMessage(responseBody) + logger.error( + `[${requestId}] Failed to create Grain v2 hook (${hookType}) for webhook ${webhookId}. Status: ${response.status}`, + { message, response: responseBody } + ) + throw new Error(grainUserFacingError(response.status, message)) + } + + const hookId = responseBody.id as string | undefined + if (!hookId) { + throw new Error( + `Grain webhook (${hookType}) created but no webhook ID was returned in the response.` + ) + } + createdIds.push(hookId) + } + return createdIds + } catch (error) { + await Promise.allSettled( + createdIds.map((hookId) => + deleteGrainV2Hook({ apiKey, hookId, requestId }).catch(() => undefined) + ) + ) + throw error + } +} + +async function deleteGrainV2Hook(params: { + apiKey: string + hookId: string + requestId: string +}): Promise { + const response = await fetch(`${GRAIN_V2_HOOKS_BASE}/${params.hookId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + 'Public-Api-Version': GRAIN_API_VERSION, + }, + }) + if (!response.ok && response.status !== 404 && response.status !== 410) { + throw new Error(`Failed to delete Grain webhook ${params.hookId}: ${response.status}`) + } +} + +/** + * Legacy v1 view-scoped hook creation, preserved verbatim for triggers created + * before the v2 migration. Not remapped to v2 on purpose: v2 has no view + * scoping, so a silent remap would widen what fires the workflow. When Grain + * sunsets v1 (2026-09-07) these deploys fail with Grain's error and the user + * must reconfigure onto the Grain Events trigger. + */ +async function createLegacyV1Subscription(params: { + apiKey: string + triggerId: string | undefined + viewId: string | undefined + notificationUrl: string + requestId: string + webhookId: string +}): Promise { + const { apiKey, triggerId, viewId, notificationUrl, requestId, webhookId } = params + + if (!viewId) { + logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { + webhookId, + triggerId, + }) + throw new Error( + 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' + ) + } + + const actionMap: Record> = { + grain_item_added: ['added'], + grain_item_updated: ['updated'], + grain_recording_created: ['added'], + grain_recording_updated: ['updated'], + grain_highlight_created: ['added'], + grain_highlight_updated: ['updated'], + grain_story_created: ['added'], + } + + const eventTypeMap: Record = { + grain_webhook: [], + grain_item_added: [], + grain_item_updated: [], + grain_recording_created: ['recording_added'], + grain_recording_updated: ['recording_updated'], + grain_highlight_created: ['highlight_added'], + grain_highlight_updated: ['highlight_updated'], + grain_story_created: ['story_added'], + } + + const actions = actionMap[triggerId ?? ''] ?? [] + const eventTypes = eventTypeMap[triggerId ?? ''] ?? [] + + if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { + logger.warn( + `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, + { webhookId } + ) + } + + logger.info(`[${requestId}] Creating legacy Grain v1 webhook`, { + triggerId, + viewId, + actions, + eventTypes, + webhookId, + }) + + const requestBody: Record = { + version: 2, + hook_url: notificationUrl, + view_id: viewId, + } + if (actions.length > 0) { + requestBody.actions = actions + } + + const grainResponse = await fetch('https://api.grain.com/_/public-api/hooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await grainResponse.json().catch(() => ({}))) as Record + + if (!grainResponse.ok || responseBody.error || responseBody.errors) { + const message = grainErrorMessage(responseBody) + logger.error( + `[${requestId}] Failed to create webhook in Grain for webhook ${webhookId}. Status: ${grainResponse.status}`, + { message, response: responseBody } + ) + throw new Error(grainUserFacingError(grainResponse.status, message)) + } + + const grainWebhookId = responseBody.id as string | undefined + if (!grainWebhookId) { + logger.error( + `[${requestId}] Grain webhook creation response missing id for webhook ${webhookId}.`, + { response: responseBody } + ) + throw new Error( + 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' + ) + } + + logger.info(`[${requestId}] Successfully created webhook in Grain for webhook ${webhookId}.`, { + grainWebhookId, + eventTypes, + }) + + return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } } +} + +async function deleteLegacyV1Hook(params: { + apiKey: string + hookId: string + requestId: string +}): Promise { + const response = await fetch(`https://api.grain.com/_/public-api/hooks/${params.hookId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + }) + if (!response.ok && response.status !== 404 && response.status !== 410) { + throw new Error(`Failed to delete Grain webhook ${params.hookId}: ${response.status}`) + } +} + export const grainHandler: WebhookProviderHandler = { handleReachabilityTest(body: unknown, requestId: string) { const obj = body as Record | null @@ -48,149 +284,73 @@ export const grainHandler: WebhookProviderHandler = { return null }, + /** + * Creates external subscriptions. Each v2 trigger maps to its hook types + * (one v2 hook created per type; All Events owns one hook per type). Legacy + * view-scoped triggers are NOT remapped — they keep calling the deprecated + * v1 API unchanged until Grain sunsets it (2026-09-07), at which point their + * deploys fail with Grain's own error and users must move to the v2 + * triggers. + */ async createSubscription(ctx: SubscriptionContext): Promise { const { webhook, requestId } = ctx try { const providerConfig = getProviderConfig(webhook) const apiKey = providerConfig.apiKey as string | undefined const triggerId = providerConfig.triggerId as string | undefined - const viewId = providerConfig.viewId as string | undefined if (!apiKey) { logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { webhookId: webhook.id, }) throw new Error( - 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' + 'Grain API Key is required. Please provide your Grain access token in the trigger configuration.' ) } - if (!viewId) { - logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { - webhookId: webhook.id, - triggerId, - }) - throw new Error( - 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' - ) - } - - const actionMap: Record> = { - grain_item_added: ['added'], - grain_item_updated: ['updated'], - grain_recording_created: ['added'], - grain_recording_updated: ['updated'], - grain_highlight_created: ['added'], - grain_highlight_updated: ['updated'], - grain_story_created: ['added'], - } - - const eventTypeMap: Record = { - grain_webhook: [], - grain_item_added: [], - grain_item_updated: [], - grain_recording_created: ['recording_added'], - grain_recording_updated: ['recording_updated'], - grain_highlight_created: ['highlight_added'], - grain_highlight_updated: ['highlight_updated'], - grain_story_created: ['story_added'], - } - - const actions = actionMap[triggerId ?? ''] ?? [] - const eventTypes = eventTypeMap[triggerId ?? ''] ?? [] - - if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { - logger.warn( - `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, - { - webhookId: webhook.id, - } - ) - } - - logger.info(`[${requestId}] Creating Grain webhook`, { - triggerId, - viewId, - actions, - eventTypes, - webhookId: webhook.id, - }) - const notificationUrl = getNotificationUrl(webhook) - const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' - - const requestBody: Record = { - version: 2, - hook_url: notificationUrl, - view_id: viewId, - } - if (actions.length > 0) { - requestBody.actions = actions - } + const v2HookTypes = + GRAIN_V2_TRIGGER_TO_HOOK_TYPES[triggerId as keyof typeof GRAIN_V2_TRIGGER_TO_HOOK_TYPES] + if (v2HookTypes) { + const hookTypes = [...v2HookTypes] + logger.info(`[${requestId}] Creating Grain v2 hooks`, { + triggerId, + hookTypes, + webhookId: webhook.id, + }) - const grainResponse = await fetch(grainApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) + const externalIds = await createGrainV2Hooks({ + apiKey, + notificationUrl, + hookTypes, + requestId, + webhookId: webhook.id as string, + }) - const responseBody = (await grainResponse.json()) as Record - - if (!grainResponse.ok || responseBody.error || responseBody.errors) { - const errors = responseBody.errors as Record | undefined - const error = responseBody.error as Record | string | undefined - const errorMessage = - errors?.detail || - (typeof error === 'object' ? error?.message : undefined) || - (typeof error === 'string' ? error : undefined) || - (responseBody.message as string) || - 'Unknown Grain API error' - logger.error( - `[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`, - { message: errorMessage, response: responseBody } + logger.info( + `[${requestId}] Successfully created ${externalIds.length} Grain hook(s) for webhook ${webhook.id}.`, + { externalIds, hookTypes } ) - let userFriendlyMessage = 'Failed to create webhook subscription in Grain' - if (grainResponse.status === 401) { - userFriendlyMessage = - 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' - } else if (grainResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Grain API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { - userFriendlyMessage = `Grain error: ${errorMessage}` + return { + providerConfigUpdates: { + externalIds, + /** First id kept for backward-compatible single-id readers. */ + externalId: externalIds[0], + eventTypes: hookTypes, + }, } - - throw new Error(userFriendlyMessage) - } - - const grainWebhookId = responseBody.id as string | undefined - - if (!grainWebhookId) { - logger.error( - `[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`, - { - response: responseBody, - } - ) - throw new Error( - 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' - ) } - logger.info( - `[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`, - { - grainWebhookId, - eventTypes, - } - ) - - return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } } + return createLegacyV1Subscription({ + apiKey, + triggerId, + viewId: providerConfig.viewId as string | undefined, + notificationUrl, + requestId, + webhookId: webhook.id as string, + }) } catch (error: unknown) { const err = error as Error logger.error( @@ -204,12 +364,21 @@ export const grainHandler: WebhookProviderHandler = { } }, + /** + * Deletes every externally created hook. Rows created by the v2 path carry + * `externalIds` (one per hook type) and delete through the v2 endpoint; rows + * created before the migration carry a single `externalId` from the v1 API + * and delete through the v1 endpoint they were created with. + */ async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { const { webhook, requestId } = ctx try { const config = getProviderConfig(webhook) const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined + const isV2Row = Array.isArray(config.externalIds) + const externalIds = (isV2Row ? (config.externalIds as string[]) : [config.externalId]).filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) if (!apiKey) { logger.warn( @@ -219,7 +388,7 @@ export const grainHandler: WebhookProviderHandler = { return } - if (!externalId) { + if (externalIds.length === 0) { logger.warn( `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` ) @@ -227,25 +396,25 @@ export const grainHandler: WebhookProviderHandler = { return } - const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` - - const grainResponse = await fetch(grainApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }) + const failures: string[] = [] + for (const externalId of externalIds) { + try { + if (isV2Row) { + await deleteGrainV2Hook({ apiKey, hookId: externalId, requestId }) + } else { + await deleteLegacyV1Hook({ apiKey, hookId: externalId, requestId }) + } + logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + } catch (error) { + logger.warn(`[${requestId}] Failed to delete Grain webhook ${externalId} (non-fatal)`, { + error, + }) + failures.push(externalId) + } + } - if (!grainResponse.ok && grainResponse.status !== 404) { - const responseBody = await grainResponse.json().catch(() => ({})) - logger.warn( - `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, - { response: responseBody } - ) - if (ctx.strict) throw new Error(`Failed to delete Grain webhook: ${grainResponse.status}`) - } else { - logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + if (failures.length > 0 && ctx.strict) { + throw new Error(`Failed to delete ${failures.length} Grain webhook(s)`) } } catch (error) { logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index aff02b2a341..201af195116 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -36,7 +36,11 @@ export const imapHandler: WebhookProviderHandler = { return { input: b } }, - async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + async configurePolling({ + webhook: webhookData, + requestId, + persistProviderConfig, + }: PollingConfigContext) { logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) try { @@ -50,23 +54,25 @@ export const imapHandler: WebhookProviderHandler = { return false } - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, - mailbox: providerConfig.mailbox || 'INBOX', - searchCriteria: providerConfig.searchCriteria || 'UNSEEN', - markAsRead: providerConfig.markAsRead || false, - includeAttachments: providerConfig.includeAttachments !== false, - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id as string)) + const configuredProviderConfig = { + ...providerConfig, + port: providerConfig.port || '993', + secure: providerConfig.secure !== false, + mailbox: providerConfig.mailbox || 'INBOX', + searchCriteria: providerConfig.searchCriteria || 'UNSEEN', + markAsRead: providerConfig.markAsRead || false, + includeAttachments: providerConfig.includeAttachments !== false, + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + } + if (persistProviderConfig) { + await persistProviderConfig(configuredProviderConfig) + } else { + await db + .update(webhook) + .set({ providerConfig: configuredProviderConfig, updatedAt: now }) + .where(eq(webhook.id, webhookData.id as string)) + } logger.info( `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts index 01f9656bc15..f9d6727fb98 100644 --- a/apps/sim/lib/webhooks/providers/outlook.ts +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -21,7 +21,11 @@ export const outlookHandler: WebhookProviderHandler = { return { input: b } }, - async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + async configurePolling({ + webhook: webhookData, + requestId, + persistProviderConfig, + }: PollingConfigContext) { logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) try { @@ -69,32 +73,33 @@ export const outlookHandler: WebhookProviderHandler = { const now = new Date() - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll: - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : (providerConfig.maxEmailsPerPoll as number) || 25, - pollingInterval: - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : (providerConfig.pollingInterval as number) || 5, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - folderIds: providerConfig.folderIds || ['inbox'], - folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: - (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id as string)) + const configuredProviderConfig = { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll: + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25, + pollingInterval: + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + } + if (persistProviderConfig) { + await persistProviderConfig(configuredProviderConfig) + } else { + await db + .update(webhook) + .set({ providerConfig: configuredProviderConfig, updatedAt: now }) + .where(eq(webhook.id, webhookData.id as string)) + } logger.info( `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index e6417472c1f..ac89b403fa8 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -8,6 +8,7 @@ import { calcomHandler } from '@/lib/webhooks/providers/calcom' import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' import { clerkHandler } from '@/lib/webhooks/providers/clerk' +import { clickupHandler } from '@/lib/webhooks/providers/clickup' import { confluenceHandler } from '@/lib/webhooks/providers/confluence' import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison' import { fathomHandler } from '@/lib/webhooks/providers/fathom' @@ -70,6 +71,7 @@ const PROVIDER_HANDLERS: Record = { calcom: calcomHandler, circleback: circlebackHandler, clerk: clerkHandler, + clickup: clickupHandler, confluence: confluenceHandler, emailbison: emailBisonHandler, fireflies: firefliesHandler, diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts index e517fd1cda1..26f140f65d6 100644 --- a/apps/sim/lib/webhooks/providers/rss.ts +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -29,25 +29,31 @@ export const rssHandler: WebhookProviderHandler = { return { input: b } }, - async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + async configurePolling({ + webhook: webhookData, + requestId, + persistProviderConfig, + }: PollingConfigContext) { logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) try { const providerConfig = (webhookData.providerConfig as Record) || {} const now = new Date() - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - lastCheckedTimestamp: now.toISOString(), - lastSeenGuids: [], - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id as string)) + const configuredProviderConfig = { + ...providerConfig, + lastCheckedTimestamp: now.toISOString(), + lastSeenGuids: [], + setupCompleted: true, + } + if (persistProviderConfig) { + await persistProviderConfig(configuredProviderConfig) + } else { + await db + .update(webhook) + .set({ providerConfig: configuredProviderConfig, updatedAt: now }) + .where(eq(webhook.id, webhookData.id as string)) + } logger.info( `[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}` diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index b773b1e3fd1..45a6dd5b03e 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -9,7 +9,6 @@ export interface AuthContext { requestId: string providerConfig: Record } - /** Context for event matching against trigger configuration. */ export interface EventMatchContext { webhook: Record @@ -82,6 +81,11 @@ export interface DeleteSubscriptionContext { export interface PollingConfigContext { webhook: Record requestId: string + /** + * Stable registration preparation supplies a generation-fenced persistence callback. + * Legacy callers omit it and retain the existing provider-owned write behavior. + */ + persistProviderConfig?(providerConfig: Record): Promise } /** diff --git a/apps/sim/lib/webhooks/registration-identity.test.ts b/apps/sim/lib/webhooks/registration-identity.test.ts new file mode 100644 index 00000000000..07400992aa2 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-identity.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + fingerprintDesiredWebhookRegistration, + normalizeWebhookRegistrationPath, +} from '@/lib/webhooks/registration-identity' + +function permutations(values: readonly T[]): T[][] { + if (values.length <= 1) return [[...values]] + + return values.flatMap((value, index) => + permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((rest) => [ + value, + ...rest, + ]) + ) +} + +const BASE_IDENTITY = { + provider: 'example', + path: 'events/incoming', + routingKey: 'tenant-1', +} as const + +describe('fingerprintDesiredWebhookRegistration', () => { + it('is invariant across deep object key orderings', () => { + const outerEntries = [ + ['enabled', false], + ['filter', { zeta: 0, alpha: '', nested: { second: null, first: 'value' } }], + ['topics', [{ beta: 2, alpha: 1 }, 'created']], + ] as const + const nestedEntries = [ + ['zeta', 0], + ['alpha', ''], + ['nested', { second: null, first: 'value' }], + ] as const + + const fingerprints = new Set() + for (const outerOrder of permutations(outerEntries)) { + for (const nestedOrder of permutations(nestedEntries)) { + const desiredConfig = Object.fromEntries(outerOrder) as Record + desiredConfig.filter = Object.fromEntries(nestedOrder) + fingerprints.add(fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig })) + } + } + + expect(fingerprints.size).toBe(1) + }) + + it('is stable as provider-managed and polling state changes outside the desired projection', () => { + const desiredConfig = { + credentialId: 'credential-1', + eventType: 'message.created', + includeThreads: false, + } + const expected = fingerprintDesiredWebhookRegistration({ + ...BASE_IDENTITY, + desiredConfig, + }) + const managedStateVariants = Array.from({ length: 32 }, (_, index) => ({ + externalSubscriptionId: `external-${index}`, + historyId: String(10_000 + index), + lastCheckedTimestamp: new Date(1_700_000_000_000 + index * 1000).toISOString(), + lastSeenGuids: [`guid-${index}`], + setupCompleted: index % 2 === 0, + subscriptionExpiration: new Date(1_800_000_000_000 + index * 1000).toISOString(), + })) + + for (const managedState of managedStateVariants) { + const persistedProviderConfig = { ...desiredConfig, ...managedState } + expect(persistedProviderConfig).toMatchObject(managedState) + expect( + fingerprintDesiredWebhookRegistration({ + ...BASE_IDENTITY, + desiredConfig, + }) + ).toBe(expected) + } + }) + + it('preserves null, false, zero, empty, undefined, and missing distinctions', () => { + const variants: ReadonlyArray> = [ + { value: null }, + { value: false }, + { value: 0 }, + { value: '' }, + { value: {} }, + { value: [] }, + { value: undefined }, + {}, + ] + + const fingerprints = variants.map((desiredConfig) => + fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig }) + ) + + expect(new Set(fingerprints).size).toBe(variants.length) + }) + + it('normalizes equivalent callback paths without collapsing null and empty paths', () => { + const desiredConfig = { eventType: 'created' } + const canonical = fingerprintDesiredWebhookRegistration({ + ...BASE_IDENTITY, + path: 'events/incoming', + desiredConfig, + }) + + expect( + fingerprintDesiredWebhookRegistration({ + ...BASE_IDENTITY, + path: ' /events/incoming/ ', + desiredConfig, + }) + ).toBe(canonical) + expect(normalizeWebhookRegistrationPath(null)).toBeNull() + expect(normalizeWebhookRegistrationPath(' / ')).toBe('') + expect( + fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, path: null, desiredConfig }) + ).not.toBe(fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, path: '', desiredConfig })) + }) + + it('rejects cyclic desired config instead of emitting an unstable fingerprint', () => { + const desiredConfig: Record = {} + desiredConfig.self = desiredConfig + + expect(() => + fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig }) + ).toThrow('cannot contain cycles') + }) +}) diff --git a/apps/sim/lib/webhooks/registration-identity.ts b/apps/sim/lib/webhooks/registration-identity.ts new file mode 100644 index 00000000000..b34907ffbb4 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-identity.ts @@ -0,0 +1,99 @@ +import { sha256Hex } from '@sim/security/hash' +import { isPlainRecord } from '@sim/utils/object' + +export interface DesiredWebhookRegistrationIdentity { + provider: string + path: string | null + routingKey: string | null + /** + * The user-controlled projection produced while building the desired trigger configuration. + * Provider-managed subscription metadata and polling cursors must not be included. + */ + desiredConfig: Readonly> +} + +type CanonicalValue = + | ['array', CanonicalValue[]] + | ['bigint', string] + | ['boolean', boolean] + | ['null'] + | ['number', string] + | ['object', Array<[string, CanonicalValue]>] + | ['string', string] + | ['undefined'] + +/** Normalizes a webhook path for desired-registration identity comparisons. */ +export function normalizeWebhookRegistrationPath(path: string | null): string | null { + if (path === null) return null + return path.trim().replace(/^\/+|\/+$/g, '') +} + +function canonicalizeNumber(value: number): string { + if (Number.isNaN(value)) return 'NaN' + if (value === Number.POSITIVE_INFINITY) return 'Infinity' + if (value === Number.NEGATIVE_INFINITY) return '-Infinity' + if (Object.is(value, -0)) return '-0' + return String(value) +} + +function canonicalize(value: unknown, ancestors: Set): CanonicalValue { + if (value === null) return ['null'] + if (value === undefined) return ['undefined'] + if (typeof value === 'boolean') return ['boolean', value] + if (typeof value === 'number') return ['number', canonicalizeNumber(value)] + if (typeof value === 'string') return ['string', value] + if (typeof value === 'bigint') return ['bigint', value.toString()] + + if (Array.isArray(value)) { + if (ancestors.has(value)) { + throw new TypeError('Desired webhook registration config cannot contain cycles') + } + ancestors.add(value) + try { + return ['array', value.map((entry) => canonicalize(entry, ancestors))] + } finally { + ancestors.delete(value) + } + } + + if (isPlainRecord(value)) { + if (ancestors.has(value)) { + throw new TypeError('Desired webhook registration config cannot contain cycles') + } + ancestors.add(value) + try { + const entries = Object.keys(value) + .sort() + .map<[string, CanonicalValue]>((key) => [key, canonicalize(value[key], ancestors)]) + return ['object', entries] + } finally { + ancestors.delete(value) + } + } + + throw new TypeError( + `Unsupported desired webhook registration config value: ${Object.prototype.toString.call(value)}` + ) +} + +/** + * Returns a deterministic fingerprint for a desired webhook registration. + * + * The caller must pass the explicit user-controlled config projection built from the trigger, + * never a persisted providerConfig row that may contain mutable provider or polling state. + */ +export function fingerprintDesiredWebhookRegistration( + identity: DesiredWebhookRegistrationIdentity +): string { + const canonicalIdentity = canonicalize( + { + provider: identity.provider, + path: normalizeWebhookRegistrationPath(identity.path), + routingKey: identity.routingKey, + desiredConfig: identity.desiredConfig, + }, + new Set() + ) + + return sha256Hex(JSON.stringify(canonicalIdentity)) +} diff --git a/apps/sim/lib/webhooks/registration-reconciliation.test.ts b/apps/sim/lib/webhooks/registration-reconciliation.test.ts new file mode 100644 index 00000000000..e6f623024e9 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-reconciliation.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type ExistingWebhookRegistration, + planWebhookRegistrationReconciliation, +} from '@/lib/webhooks/registration-reconciliation' + +interface RegistrationRow { + id: string + providerConfig: Record +} + +function existingRegistration( + triggerId: string, + fingerprint: string | null, + generation: number, + providerConfig: Record = {} +): ExistingWebhookRegistration { + return { + triggerId, + fingerprint, + generation, + row: { + id: `row-${triggerId}`, + providerConfig, + }, + } +} + +describe('planWebhookRegistrationReconciliation', () => { + it('reuses an unchanged physical row across generations with provider state intact', () => { + const providerConfig = { + credentialId: 'credential-1', + externalSubscriptionId: 'external-1', + historyId: 'history-9', + setupCompleted: true, + } + const existing = existingRegistration('trigger-1', 'fingerprint-1', 4, providerConfig) + + const plan = planWebhookRegistrationReconciliation({ + generation: 5, + desired: [ + { + triggerId: 'trigger-1', + fingerprint: 'fingerprint-1', + desired: { provider: 'example' }, + }, + ], + existing: [existing], + }) + + expect(plan.actions).toEqual([ + expect.objectContaining({ + kind: 'reuse', + triggerId: 'trigger-1', + }), + ]) + const [action] = plan.actions + expect(action.kind).toBe('reuse') + if (action.kind !== 'reuse') throw new Error('Expected reuse action') + expect(action.existing).toBe(existing) + expect(action.existing.row).toBe(existing.row) + expect(action.existing.row.id).toBe('row-trigger-1') + expect(action.existing.row.providerConfig).toBe(providerConfig) + }) + + it('prepares candidates for changed and missing registrations without mutating current rows', () => { + const changed = existingRegistration('changed', 'old-fingerprint', 7, { + externalId: 'external-1', + }) + const before = structuredClone(changed) + + const plan = planWebhookRegistrationReconciliation({ + generation: 7, + desired: [ + { + triggerId: 'changed', + fingerprint: 'new-fingerprint', + desired: { provider: 'example', event: 'updated' }, + }, + { + triggerId: 'new', + fingerprint: 'new-trigger-fingerprint', + desired: { provider: 'example', event: 'created' }, + }, + ], + existing: [changed], + }) + + expect(plan.actions).toEqual([ + expect.objectContaining({ + kind: 'prepare_candidate', + triggerId: 'changed', + existing: changed, + }), + expect.objectContaining({ + kind: 'prepare_candidate', + triggerId: 'new', + existing: null, + }), + ]) + expect(changed).toEqual(before) + }) + + it('leaves removed-row retirement to the atomic activation step', () => { + const kept = existingRegistration('kept', 'same', 2) + const removed = existingRegistration('removed', 'old', 2) + + const plan = planWebhookRegistrationReconciliation({ + generation: 3, + desired: [{ triggerId: 'kept', fingerprint: 'same', desired: {} }], + existing: [kept, removed], + }) + + expect(plan.actions.map((action) => action.kind)).toEqual(['reuse']) + }) + + it('rejects a stale generation before planning over newer rows', () => { + const newer = existingRegistration('trigger-1', 'same', 11) + + expect(() => + planWebhookRegistrationReconciliation({ + generation: 10, + desired: [{ triggerId: 'trigger-1', fingerprint: 'same', desired: {} }], + existing: [newer], + }) + ).toThrow('newer registration generation 11') + }) + + it.each([ + { + label: 'desired', + desired: [ + { triggerId: 'duplicate', fingerprint: 'one', desired: {} }, + { triggerId: 'duplicate', fingerprint: 'two', desired: {} }, + ], + existing: [], + }, + { + label: 'existing', + desired: [], + existing: [ + existingRegistration('duplicate', 'one', 1), + existingRegistration('duplicate', 'two', 1), + ], + }, + ])('rejects duplicate trigger identities in $label registrations', ({ desired, existing }) => { + expect(() => + planWebhookRegistrationReconciliation({ + generation: 1, + desired, + existing, + }) + ).toThrow('duplicate triggerId "duplicate"') + }) +}) diff --git a/apps/sim/lib/webhooks/registration-reconciliation.ts b/apps/sim/lib/webhooks/registration-reconciliation.ts new file mode 100644 index 00000000000..451857f10cf --- /dev/null +++ b/apps/sim/lib/webhooks/registration-reconciliation.ts @@ -0,0 +1,110 @@ +export interface DesiredWebhookRegistration { + triggerId: string + fingerprint: string + desired: TDesired +} + +export interface ExistingWebhookRegistration { + triggerId: string + generation: number + fingerprint: string | null + row: TRow +} + +export type WebhookRegistrationReconciliationAction = + | { + kind: 'reuse' + triggerId: string + desired: DesiredWebhookRegistration + existing: ExistingWebhookRegistration + } + | { + kind: 'prepare_candidate' + triggerId: string + desired: DesiredWebhookRegistration + existing: ExistingWebhookRegistration | null + } + +export interface WebhookRegistrationReconciliationPlan { + actions: Array> +} + +function assertGeneration(generation: number, label: string): void { + if (!Number.isSafeInteger(generation) || generation < 0) { + throw new TypeError(`${label} must be a non-negative safe integer`) + } +} + +function indexUniqueByTriggerId( + entries: readonly T[], + label: string +): Map { + const entriesByTriggerId = new Map() + for (const entry of entries) { + if (!entry.triggerId) { + throw new TypeError(`${label} triggerId cannot be empty`) + } + if (entriesByTriggerId.has(entry.triggerId)) { + throw new TypeError(`${label} contains duplicate triggerId "${entry.triggerId}"`) + } + entriesByTriggerId.set(entry.triggerId, entry) + } + return entriesByTriggerId +} + +/** + * Produces the side-effect-free registration work for one deployment generation. + * + * Fingerprint matches reuse the exact persisted row object, retaining its physical ID and + * provider-managed state. Changed or missing registrations prepare candidates, while registrations + * absent from the desired trigger set are retired. A stale generation cannot act on newer rows. + */ +export function planWebhookRegistrationReconciliation< + TDesired, + TRow extends { id: string }, +>(input: { + generation: number + desired: readonly DesiredWebhookRegistration[] + existing: readonly ExistingWebhookRegistration[] +}): WebhookRegistrationReconciliationPlan { + assertGeneration(input.generation, 'Reconciliation generation') + + indexUniqueByTriggerId(input.desired, 'Desired registrations') + const existingByTriggerId = indexUniqueByTriggerId(input.existing, 'Existing registrations') + + for (const existing of input.existing) { + assertGeneration( + existing.generation, + `Existing registration "${existing.triggerId}" generation` + ) + if (existing.generation > input.generation) { + throw new Error( + `Cannot reconcile generation ${input.generation} over newer registration generation ${existing.generation} for trigger "${existing.triggerId}"` + ) + } + } + + const actions: Array> = [] + + for (const desired of input.desired) { + const existing = existingByTriggerId.get(desired.triggerId) + if (existing?.fingerprint === desired.fingerprint) { + actions.push({ + kind: 'reuse', + triggerId: desired.triggerId, + desired, + existing, + }) + continue + } + + actions.push({ + kind: 'prepare_candidate', + triggerId: desired.triggerId, + desired, + existing: existing ?? null, + }) + } + + return { actions } +} diff --git a/apps/sim/lib/webhooks/registration-service.test.ts b/apps/sim/lib/webhooks/registration-service.test.ts new file mode 100644 index 00000000000..8da7ba00d30 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-service.test.ts @@ -0,0 +1,349 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { providerHandler } = vi.hoisted(() => ({ + providerHandler: { + createSubscription: vi.fn(), + }, +})) + +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: vi.fn(() => providerHandler), +})) + +import type { NextRequest } from 'next/server' +import { + cleanupRetiredWebhookRegistrationsAfterActivation, + prepareStableWebhookRegistrations, + type StableWebhookRegistrationDependencies, +} from '@/lib/webhooks/registration-service' +import { + buildLegacyInvisibleCandidateValues, + type DesiredWebhookRegistrationIntent, + type WebhookRegistrationOperationFence, + type WebhookRegistrationRow, +} from '@/lib/webhooks/registration-store' + +const fence: WebhookRegistrationOperationFence = { + workflowId: 'workflow-1', + operationId: 'operation-5', + generation: 5, + deploymentVersionId: 'version-5', +} + +function registrationRow(overrides: Partial = {}): WebhookRegistrationRow { + return { + id: 'webhook-1', + workflowId: fence.workflowId, + deploymentVersionId: 'version-4', + registrationStatus: 'active', + registrationGeneration: 4, + configFingerprint: 'old-fingerprint', + preparedAt: new Date('2026-01-01T00:00:00Z'), + blockId: 'trigger-1', + path: 'events', + routingKey: null, + provider: 'parallel-provider', + providerConfig: { externalId: 'external-old', cursor: 'cursor-9' }, + isActive: true, + failedCount: 3, + lastFailedAt: new Date('2026-01-02T00:00:00Z'), + archivedAt: null, + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + ...overrides, + } +} + +function dependencies( + overrides: Partial = {} +): StableWebhookRegistrationDependencies { + return { + prepareIntents: vi.fn(), + checkpointCandidate: vi.fn(), + listRetired: vi.fn(), + getCleanupSnapshot: vi.fn(), + deleteAfterCleanup: vi.fn(), + createExternal: vi.fn(), + cleanupExternal: vi.fn(), + ...overrides, + } as unknown as StableWebhookRegistrationDependencies +} + +describe('stable webhook registration service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists candidate intent as invisible to legacy delivery queries', () => { + const now = new Date('2026-07-14T00:00:00Z') + const desired: DesiredWebhookRegistrationIntent = { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: '/events/', + routingKey: null, + providerConfig: { event: 'created' }, + configFingerprint: 'fingerprint-5', + } + + const values = buildLegacyInvisibleCandidateValues({ + id: 'candidate-5', + fence, + desired, + now, + }) + + expect(values).toMatchObject({ + registrationStatus: 'candidate', + registrationGeneration: 5, + path: 'events', + isActive: false, + archivedAt: now, + preparedAt: null, + }) + }) + + it('never touches the live subscription when candidate preparation fails', async () => { + const candidate = registrationRow({ + id: 'candidate-5', + deploymentVersionId: fence.deploymentVersionId, + registrationStatus: 'candidate', + registrationGeneration: fence.generation, + configFingerprint: 'new-fingerprint', + preparedAt: null, + providerConfig: { event: 'updated' }, + isActive: false, + failedCount: 0, + lastFailedAt: null, + archivedAt: new Date('2026-07-14T00:00:00Z'), + }) + const createExternal = vi.fn().mockRejectedValue(new Error('provider unavailable')) + const cleanupExternal = vi.fn() + const checkpointCandidate = vi.fn() + const store = dependencies({ + prepareIntents: vi.fn().mockResolvedValue({ + candidates: [ + { + desired: { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: 'events', + routingKey: null, + providerConfig: { event: 'updated' }, + configFingerprint: 'new-fingerprint', + }, + row: candidate, + }, + ], + orphanedCandidates: [], + }), + createExternal, + cleanupExternal, + checkpointCandidate, + }) + + await expect( + prepareStableWebhookRegistrations( + { + request: {} as NextRequest, + fence, + workflow: { id: fence.workflowId }, + userId: 'user-1', + requestId: 'request-1', + desired: [ + { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: 'events', + routingKey: null, + providerConfig: { event: 'updated' }, + desiredConfig: { event: 'updated' }, + }, + ], + }, + store + ) + ).rejects.toThrow('Failed to prepare 1 webhook registration') + + expect(createExternal).toHaveBeenCalledTimes(1) + expect(cleanupExternal).not.toHaveBeenCalled() + expect(checkpointCandidate).not.toHaveBeenCalled() + }) + + it('durably records the external subscription before finishing preparation', async () => { + const candidate = registrationRow({ + id: 'candidate-5', + deploymentVersionId: fence.deploymentVersionId, + registrationStatus: 'candidate', + registrationGeneration: fence.generation, + configFingerprint: 'new-fingerprint', + preparedAt: null, + providerConfig: null, + isActive: false, + archivedAt: new Date('2026-07-14T00:00:00Z'), + }) + const abortController = new AbortController() + const createExternal = vi.fn().mockResolvedValue({ + updatedProviderConfig: { event: 'updated', externalId: 'external-new' }, + externalSubscriptionCreated: true, + }) + const cleanupExternal = vi.fn() + const checkpointCandidate = vi.fn() + const store = dependencies({ + prepareIntents: vi.fn().mockResolvedValue({ + candidates: [ + { + desired: { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: 'events', + routingKey: null, + providerConfig: { event: 'updated' }, + configFingerprint: 'new-fingerprint', + }, + row: candidate, + }, + ], + orphanedCandidates: [], + }), + createExternal, + cleanupExternal, + checkpointCandidate, + }) + + await prepareStableWebhookRegistrations( + { + request: {} as NextRequest, + fence, + workflow: { id: fence.workflowId }, + userId: 'user-1', + requestId: 'request-1', + signal: abortController.signal, + desired: [ + { + blockId: 'trigger-1', + provider: 'parallel-provider', + path: 'events', + routingKey: null, + providerConfig: { event: 'updated' }, + desiredConfig: { event: 'updated' }, + }, + ], + }, + store + ) + + expect(cleanupExternal).not.toHaveBeenCalled() + expect(createExternal.mock.calls[0][5]).toEqual({ signal: abortController.signal }) + expect(checkpointCandidate).toHaveBeenCalledTimes(2) + expect(checkpointCandidate.mock.calls[0][0]).toEqual( + expect.objectContaining({ + prepared: false, + providerConfig: { event: 'updated', externalId: 'external-new' }, + }) + ) + expect(checkpointCandidate.mock.calls[1][0]).not.toHaveProperty('prepared') + }) + + it('cleans a never-prepared ghost candidate best-effort so new deploys are not wedged', async () => { + const ghostOrphan = registrationRow({ + id: 'ghost-orphan', + registrationStatus: 'orphaned', + registrationGeneration: 4, + preparedAt: null, + providerConfig: { event: 'created' }, + isActive: false, + archivedAt: new Date('2026-07-14T00:00:00Z'), + }) + const cleanupExternal = vi.fn() + const deleteAfterCleanup = vi.fn().mockResolvedValue(true) + const checkpointCandidate = vi.fn() + const store = dependencies({ + prepareIntents: vi.fn().mockResolvedValue({ + candidates: [], + orphanedCandidates: [ghostOrphan], + }), + getCleanupSnapshot: vi.fn().mockResolvedValue(ghostOrphan), + cleanupExternal, + deleteAfterCleanup, + checkpointCandidate, + }) + + await prepareStableWebhookRegistrations( + { + request: {} as NextRequest, + fence, + workflow: { id: fence.workflowId }, + userId: 'user-1', + requestId: 'request-ghost', + desired: [], + }, + store + ) + + expect(cleanupExternal).toHaveBeenCalledWith(ghostOrphan, expect.anything(), 'request-ghost', { + throwOnError: false, + }) + expect(deleteAfterCleanup).toHaveBeenCalledTimes(1) + }) + + it('keeps strict external cleanup for retired rows that served traffic', async () => { + const retired = registrationRow({ + registrationStatus: 'retired', + isActive: false, + archivedAt: new Date('2026-07-14T00:00:00Z'), + }) + const cleanupExternal = vi.fn() + const deleteAfterCleanup = vi.fn().mockResolvedValue(true) + const store = dependencies({ + listRetired: vi.fn().mockResolvedValueOnce([retired]).mockResolvedValue([]), + getCleanupSnapshot: vi.fn().mockResolvedValue(retired), + cleanupExternal, + deleteAfterCleanup, + }) + + await cleanupRetiredWebhookRegistrationsAfterActivation( + { + fence, + workflow: { id: fence.workflowId }, + requestId: 'request-retired', + }, + store + ) + + expect(cleanupExternal).toHaveBeenCalledWith(retired, expect.anything(), 'request-retired', { + throwOnError: true, + }) + expect(deleteAfterCleanup).toHaveBeenCalledTimes(1) + }) + + it('skips external cleanup when the row was reused by a newer generation', async () => { + const staleRetired = registrationRow({ + registrationStatus: 'retired', + isActive: false, + archivedAt: new Date(), + }) + const cleanupExternal = vi.fn() + const deleteAfterCleanup = vi.fn() + const store = dependencies({ + listRetired: vi.fn().mockResolvedValueOnce([staleRetired]).mockResolvedValue([]), + getCleanupSnapshot: vi.fn().mockResolvedValue(null), + cleanupExternal, + deleteAfterCleanup, + }) + + await cleanupRetiredWebhookRegistrationsAfterActivation( + { + fence, + workflow: { id: fence.workflowId }, + requestId: 'request-cleanup', + }, + store + ) + + expect(cleanupExternal).not.toHaveBeenCalled() + expect(deleteAfterCleanup).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/registration-service.ts b/apps/sim/lib/webhooks/registration-service.ts new file mode 100644 index 00000000000..4a650143049 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-service.ts @@ -0,0 +1,390 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' +import { + cleanupExternalWebhook, + createExternalWebhookSubscription, +} from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { fingerprintDesiredWebhookRegistration } from '@/lib/webhooks/registration-identity' +import { + checkpointWebhookCandidate, + type DesiredWebhookRegistrationIntent, + deleteWebhookRegistrationAfterCleanup, + getWebhookCleanupSnapshotIfCurrent, + listRetiredWebhookRegistrationsForCleanup, + type PreparedWebhookCandidate, + type PreparedWebhookRegistrationWork, + prepareWebhookRegistrationIntents, + type WebhookRegistrationOperationFence, + type WebhookRegistrationRow, +} from '@/lib/webhooks/registration-store' + +const logger = createLogger('StableWebhookRegistration') + +export interface StableDesiredWebhookRegistration { + blockId: string + provider: string + path: string | null + routingKey: string | null + providerConfig: Record + desiredConfig: Readonly> +} + +export interface StableWebhookRegistrationDependencies { + prepareIntents(input: { + fence: WebhookRegistrationOperationFence + desired: readonly DesiredWebhookRegistrationIntent[] + }): Promise + checkpointCandidate: typeof checkpointWebhookCandidate + listRetired: typeof listRetiredWebhookRegistrationsForCleanup + getCleanupSnapshot: typeof getWebhookCleanupSnapshotIfCurrent + deleteAfterCleanup: typeof deleteWebhookRegistrationAfterCleanup + createExternal: typeof createExternalWebhookSubscription + cleanupExternal: typeof cleanupExternalWebhook +} + +const DEFAULT_DEPENDENCIES: StableWebhookRegistrationDependencies = { + prepareIntents: prepareWebhookRegistrationIntents, + checkpointCandidate: checkpointWebhookCandidate, + listRetired: listRetiredWebhookRegistrationsForCleanup, + getCleanupSnapshot: getWebhookCleanupSnapshotIfCurrent, + deleteAfterCleanup: deleteWebhookRegistrationAfterCleanup, + createExternal: createExternalWebhookSubscription, + cleanupExternal: cleanupExternalWebhook, +} + +export interface PrepareStableWebhookRegistrationsInput { + request: NextRequest + fence: WebhookRegistrationOperationFence + workflow: Record + userId: string + requestId: string + desired: readonly StableDesiredWebhookRegistration[] + signal?: AbortSignal +} + +function buildDesiredIntents( + desired: readonly StableDesiredWebhookRegistration[] +): DesiredWebhookRegistrationIntent[] { + return desired.map((registration) => ({ + blockId: registration.blockId, + provider: registration.provider, + path: registration.path, + routingKey: registration.routingKey, + providerConfig: registration.providerConfig, + configFingerprint: fingerprintDesiredWebhookRegistration({ + provider: registration.provider, + path: registration.path, + routingKey: registration.routingKey, + desiredConfig: registration.desiredConfig, + }), + })) +} + +async function cleanupGenerationFencedRegistration( + row: WebhookRegistrationRow, + workflow: Record, + requestId: string, + statuses: readonly ('candidate' | 'orphaned' | 'retired')[], + dependencies: StableWebhookRegistrationDependencies +): Promise { + if (row.registrationGeneration === null) return false + const snapshot = await dependencies.getCleanupSnapshot({ + workflowId: row.workflowId, + webhookId: row.id, + expectedGeneration: row.registrationGeneration, + statuses, + }) + if (!snapshot) return false + + /** + * Strict cleanup only for rows that verifiably held a live subscription: + * retired rows served traffic and prepared candidates completed provider + * setup. A never-prepared ghost (its create failed) has partial or no + * external state — strict-failing on it would wedge every future deploy + * behind an uncleanable leftover, so those clean up best-effort instead. + */ + const holdsVerifiedSubscription = + snapshot.registrationStatus === 'retired' || snapshot.preparedAt !== null + await dependencies.cleanupExternal(snapshot, workflow, requestId, { + throwOnError: holdsVerifiedSubscription, + }) + return dependencies.deleteAfterCleanup({ + workflowId: snapshot.workflowId, + webhookId: snapshot.id, + expectedGeneration: row.registrationGeneration, + statuses, + }) +} + +async function createCandidateProviderState( + input: PrepareStableWebhookRegistrationsInput, + candidate: PreparedWebhookCandidate, + dependencies: StableWebhookRegistrationDependencies +): Promise> { + const webhookData = { + ...candidate.row, + provider: candidate.desired.provider, + providerConfig: candidate.row.providerConfig ?? candidate.desired.providerConfig, + } + const handler = getProviderHandler(candidate.desired.provider) + + const externalResult = await dependencies.createExternal( + input.request, + webhookData, + input.workflow, + input.userId, + input.requestId, + { signal: input.signal } + ) + let providerConfig = externalResult.updatedProviderConfig + + if (externalResult.externalSubscriptionCreated) { + /** + * Persist the provider-returned state immediately so the external + * subscription is never unrecorded: if the lease aborts or the process + * dies between here and the final checkpoint, the retry (or orphan + * cleanup) can delete this subscription from the row instead of leaking + * it and creating a duplicate. + */ + await dependencies.checkpointCandidate({ + fence: input.fence, + webhookId: candidate.row.id, + providerConfig, + prepared: false, + }) + } + + if (handler.configurePolling) { + let persistedProviderConfig: Record | undefined + const configured = await handler.configurePolling({ + webhook: { ...webhookData, providerConfig }, + requestId: input.requestId, + persistProviderConfig: async (configuredProviderConfig) => { + persistedProviderConfig = configuredProviderConfig + await dependencies.checkpointCandidate({ + fence: input.fence, + webhookId: candidate.row.id, + providerConfig: configuredProviderConfig, + prepared: false, + }) + return true + }, + }) + if (!configured) { + throw new Error(`Failed to configure ${candidate.desired.provider} polling`) + } + if (persistedProviderConfig) { + providerConfig = persistedProviderConfig + } else { + const configuredRow = await dependencies.getCleanupSnapshot({ + workflowId: input.fence.workflowId, + webhookId: candidate.row.id, + expectedGeneration: input.fence.generation, + statuses: ['candidate'], + }) + if (!configuredRow) { + throw new Error('Webhook candidate became stale while configuring polling') + } + if (configuredRow.providerConfig && typeof configuredRow.providerConfig === 'object') { + providerConfig = configuredRow.providerConfig as Record + } + } + } + + return providerConfig +} + +/** + * Prepares one candidate registration without ever touching the currently + * serving external subscription: the candidate's subscription is created + * alongside the live one, and the old subscription is deleted only after + * activation retires its row (cleanupRetiredWebhookRegistrationsAfterActivation). + * A failed or superseded attempt therefore leaves live delivery intact — the + * candidate's own external state is rolled back or garbage-collected as an + * orphan on the next preparation. + * + * Providers with singleton registrations per credential (e.g. Telegram + * setWebhook) implicitly repoint on create; their delete handlers already + * skip teardown while an active deployment uses the same credential, so the + * retired-row cleanup after cutover cannot disturb the new subscription. + */ +async function prepareCandidate( + input: PrepareStableWebhookRegistrationsInput, + candidate: PreparedWebhookCandidate, + dependencies: StableWebhookRegistrationDependencies +): Promise { + if (candidate.row.preparedAt) return + input.signal?.throwIfAborted() + + const handler: WebhookProviderHandler = getProviderHandler(candidate.desired.provider) + const hasProviderPreparation = Boolean(handler.createSubscription || handler.configurePolling) + + if (!hasProviderPreparation) { + await dependencies.checkpointCandidate({ + fence: input.fence, + webhookId: candidate.row.id, + providerConfig: candidate.desired.providerConfig, + }) + return + } + + const verificationTracker = new PendingWebhookVerificationTracker() + let preparedProviderConfig: Record | undefined + try { + if (candidate.row.path) { + await verificationTracker.register({ + path: candidate.row.path, + provider: candidate.desired.provider, + workflowId: input.fence.workflowId, + blockId: candidate.desired.blockId, + metadata: candidate.desired.providerConfig, + }) + } + + input.signal?.throwIfAborted() + preparedProviderConfig = await createCandidateProviderState(input, candidate, dependencies) + input.signal?.throwIfAborted() + await dependencies.checkpointCandidate({ + fence: input.fence, + webhookId: candidate.row.id, + providerConfig: preparedProviderConfig, + }) + } catch (error) { + if (preparedProviderConfig) { + try { + const currentCandidate = await dependencies.getCleanupSnapshot({ + workflowId: input.fence.workflowId, + webhookId: candidate.row.id, + expectedGeneration: input.fence.generation, + statuses: ['candidate'], + }) + if (currentCandidate) { + await dependencies.cleanupExternal( + { ...currentCandidate, providerConfig: preparedProviderConfig }, + input.workflow, + input.requestId, + { throwOnError: true } + ) + } + } catch (cleanupError) { + logger.error('Failed to rollback an uncheckpointed webhook candidate', { + workflowId: input.fence.workflowId, + webhookId: candidate.row.id, + error: toError(cleanupError).message, + }) + } + } + throw error + } finally { + await verificationTracker.clearAll() + } +} + +/** + * Prepares each registration action independently while leaving the currently active set untouched. + */ +export async function prepareStableWebhookRegistrations( + input: PrepareStableWebhookRegistrationsInput, + dependencies: StableWebhookRegistrationDependencies = DEFAULT_DEPENDENCIES +): Promise { + input.signal?.throwIfAborted() + const work = await dependencies.prepareIntents({ + fence: input.fence, + desired: buildDesiredIntents(input.desired), + }) + + const failures: Error[] = [] + const failureReasons: string[] = [] + const blocksWithFailedOrphanCleanup = new Set() + for (const orphaned of work.orphanedCandidates) { + try { + await cleanupGenerationFencedRegistration( + orphaned, + input.workflow, + input.requestId, + ['orphaned'], + dependencies + ) + } catch (error) { + failures.push(toError(error)) + failureReasons.push(`${orphaned.provider ?? 'webhook'} cleanup: ${toError(error).message}`) + if (orphaned.blockId) blocksWithFailedOrphanCleanup.add(orphaned.blockId) + } + } + + for (const candidate of work.candidates) { + if (blocksWithFailedOrphanCleanup.has(candidate.desired.blockId)) continue + try { + await prepareCandidate(input, candidate, dependencies) + } catch (error) { + failures.push(toError(error)) + failureReasons.push(`${candidate.desired.provider}: ${toError(error).message}`) + logger.warn('Webhook registration candidate preparation failed', { + workflowId: input.fence.workflowId, + webhookId: candidate.row.id, + provider: candidate.desired.provider, + error: toError(error).message, + }) + } + } + + if (failures.length > 0) { + /** + * The aggregate message carries the underlying provider reasons because it + * is what gets persisted on the deployment operation and shown in the + * retrying/failed tooltips — a bare count would hide the actual cause. + */ + throw new AggregateError( + failures, + `Failed to prepare ${failures.length} webhook registration(s): ${[...new Set(failureReasons)].join('; ')}` + ) + } +} + +/** + * Cleans retired provider resources after activation without trusting stale cleanup payloads. + */ +export async function cleanupRetiredWebhookRegistrationsAfterActivation( + input: { + fence: WebhookRegistrationOperationFence + workflow: Record + requestId: string + signal?: AbortSignal + }, + dependencies: StableWebhookRegistrationDependencies = DEFAULT_DEPENDENCIES +): Promise { + while (true) { + input.signal?.throwIfAborted() + const retiredRows = await dependencies.listRetired({ ...input.fence, limit: 100 }) + if (retiredRows.length === 0) return + + const failures: Error[] = [] + const failureReasons: string[] = [] + for (const row of retiredRows) { + input.signal?.throwIfAborted() + try { + await cleanupGenerationFencedRegistration( + row, + input.workflow, + input.requestId, + ['retired'], + dependencies + ) + } catch (error) { + failures.push(toError(error)) + failureReasons.push(`${row.provider ?? 'webhook'}: ${toError(error).message}`) + } + } + + if (failures.length > 0) { + throw new AggregateError( + failures, + `Failed to clean ${failures.length} retired webhook(s): ${[...new Set(failureReasons)].join('; ')}` + ) + } + } +} diff --git a/apps/sim/lib/webhooks/registration-store.test.ts b/apps/sim/lib/webhooks/registration-store.test.ts new file mode 100644 index 00000000000..204580b899b --- /dev/null +++ b/apps/sim/lib/webhooks/registration-store.test.ts @@ -0,0 +1,337 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface Condition { + kind: string + column?: unknown + value?: unknown + conditions?: Condition[] +} + +const { mockTransaction, mockIsDeploymentOperationCurrent, mockClaimWebhookPath } = vi.hoisted( + () => ({ + mockTransaction: vi.fn(), + mockIsDeploymentOperationCurrent: vi.fn(), + mockClaimWebhookPath: vi.fn(), + }) +) + +vi.mock('@sim/db', () => ({ + db: { transaction: mockTransaction }, +})) + +vi.mock('drizzle-orm', () => ({ + and: (...conditions: Condition[]) => ({ kind: 'and', conditions }), + eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }), + gt: (column: unknown, value: unknown) => ({ kind: 'gt', column, value }), + inArray: (column: unknown, value: unknown) => ({ kind: 'inArray', column, value }), + isNull: (column: unknown) => ({ kind: 'isNull', column }), + lt: (column: unknown, value: unknown) => ({ kind: 'lt', column, value }), + lte: (column: unknown, value: unknown) => ({ kind: 'lte', column, value }), +})) + +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ + projectDesiredWebhookProviderConfig: (config: Record) => config, +})) + +vi.mock('@/lib/webhooks/path-claims', () => ({ + claimWebhookPath: mockClaimWebhookPath, +})) + +vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ + isDeploymentOperationCurrent: mockIsDeploymentOperationCurrent, +})) + +import type { DbOrTx } from '@sim/workflow-persistence/types' +import { + activateWebhookRegistrations, + prepareWebhookRegistrationIntents, + StaleWebhookRegistrationOperationError, + type WebhookRegistrationOperationFence, +} from '@/lib/webhooks/registration-store' + +const FENCE: WebhookRegistrationOperationFence = { + workflowId: 'workflow-1', + operationId: 'operation-1', + generation: 3, + deploymentVersionId: 'version-3', +} + +interface UpdateCall { + payload: Record + condition: Condition +} + +interface InsertCall { + values: Record +} + +/** + * Queue-driven transaction mock: every select drains the next result from the + * queue regardless of terminal call shape (`for`, `limit`, direct await), and + * updates/inserts capture their payloads for assertions. + */ +function createTx(selectResults: unknown[][]) { + const updates: UpdateCall[] = [] + const inserts: InsertCall[] = [] + const updateResults: unknown[][] = [] + + const nextSelect = () => { + const result = selectResults.shift() + if (!result) throw new Error('Unexpected select: result queue is empty') + return result + } + + const tx = { + select: vi.fn(() => ({ + from: vi.fn(() => { + const terminal = (result: unknown[]) => ({ + for: vi.fn(async () => result), + limit: vi.fn(async () => result), + orderBy: vi.fn(() => ({ limit: vi.fn(async () => result) })), + then: (resolve: (rows: unknown[]) => void) => resolve(result), + }) + return { + where: vi.fn(() => terminal(nextSelect())), + } + }), + })), + update: vi.fn(() => ({ + set: vi.fn((payload: Record) => ({ + where: vi.fn((condition: Condition) => { + updates.push({ payload, condition }) + const result = updateResults.shift() ?? [{ id: 'updated' }] + return { + returning: vi.fn(async () => result), + then: (resolve: (rows: unknown[]) => void) => resolve(result), + } + }), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn((values: Record) => { + inserts.push({ values }) + return { returning: vi.fn(async () => [{ ...values }]) } + }), + })), + } + + return { tx: tx as unknown as DbOrTx, updates, inserts, updateResults } +} + +function activeRow(overrides: Record = {}) { + return { + id: 'wh-active', + workflowId: 'workflow-1', + blockId: 'block-1', + provider: 'slack', + path: 'hooks/a', + routingKey: null, + providerConfig: {}, + registrationStatus: 'active', + registrationGeneration: 2, + configFingerprint: 'fp-old', + preparedAt: new Date('2026-07-01T00:00:00Z'), + isActive: true, + archivedAt: null, + deploymentVersionId: 'version-2', + updatedAt: new Date('2026-07-01T00:00:00Z'), + createdAt: new Date('2026-07-01T00:00:00Z'), + ...overrides, + } +} + +describe('activateWebhookRegistrations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsDeploymentOperationCurrent.mockResolvedValue(true) + }) + + it('rejects when candidates are not fully prepared', async () => { + const { tx, updates } = createTx([[{ id: 'workflow-1' }], [{ id: 'wh-unprepared' }]]) + + await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toThrow( + 'Webhook registration candidates are not fully prepared' + ) + expect(updates).toHaveLength(0) + }) + + it('rejects stale operations when newer generation rows exist', async () => { + const { tx, updates } = createTx([[{ id: 'workflow-1' }], [], [{ id: 'wh-newer' }]]) + + await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toBeInstanceOf( + StaleWebhookRegistrationOperationError + ) + expect(updates).toHaveLength(0) + }) + + it('rejects when the operation is no longer current', async () => { + mockIsDeploymentOperationCurrent.mockResolvedValue(false) + const { tx, updates } = createTx([[{ id: 'workflow-1' }]]) + + await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toBeInstanceOf( + StaleWebhookRegistrationOperationError + ) + expect(updates).toHaveLength(0) + }) + + it('retires older actives, repoints reused rows, and promotes candidates atomically', async () => { + const { tx, updates } = createTx([[{ id: 'workflow-1' }], [], []]) + + await activateWebhookRegistrations(tx, FENCE) + + expect(updates).toHaveLength(3) + expect(updates[0].payload).toEqual( + expect.objectContaining({ registrationStatus: 'retired', isActive: false }) + ) + expect(updates[0].payload.archivedAt).toBeInstanceOf(Date) + expect(JSON.stringify(updates[0].condition)).toContain('"lt"') + + expect(updates[1].payload).toEqual( + expect.objectContaining({ + deploymentVersionId: 'version-3', + isActive: true, + archivedAt: null, + }) + ) + + expect(updates[2].payload).toEqual( + expect.objectContaining({ + registrationStatus: 'active', + deploymentVersionId: 'version-3', + isActive: true, + archivedAt: null, + }) + ) + }) +}) + +describe('prepareWebhookRegistrationIntents', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsDeploymentOperationCurrent.mockResolvedValue(true) + mockClaimWebhookPath.mockResolvedValue('hooks/a') + mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise) => { + throw new Error('mockTransaction not configured for this test') + }) + }) + + function runInTx(selectResults: unknown[][]) { + const harness = createTx(selectResults) + mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise) => + callback(harness.tx) + ) + return harness + } + + const desired = { + blockId: 'block-1', + provider: 'slack', + path: 'hooks/a', + routingKey: null, + providerConfig: { url: 'https://example.test' }, + configFingerprint: 'fp-new', + } + + it('claims paths and writes legacy-invisible candidates for changed registrations', async () => { + const previousActive = activeRow() + const { inserts, updates } = runInTx([[{ id: 'workflow-1' }], [], [previousActive], [], []]) + + const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] }) + + expect(mockClaimWebhookPath).toHaveBeenCalledWith(expect.anything(), { + path: 'hooks/a', + workflowId: 'workflow-1', + generation: 3, + }) + expect(updates).toHaveLength(0) + expect(inserts).toHaveLength(1) + expect(inserts[0].values).toEqual( + expect.objectContaining({ + registrationStatus: 'candidate', + registrationGeneration: 3, + configFingerprint: 'fp-new', + isActive: false, + preparedAt: null, + deploymentVersionId: 'version-3', + }) + ) + expect(inserts[0].values.archivedAt).toBeInstanceOf(Date) + expect(work.candidates).toHaveLength(1) + expect(work.candidates[0].row.blockId).toBe('block-1') + }) + + it('reuses fingerprint-matched active rows by bumping their generation fence', async () => { + const reusable = activeRow({ configFingerprint: 'fp-new' }) + const { inserts, updates } = runInTx([[{ id: 'workflow-1' }], [], [reusable], [], []]) + + const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] }) + + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(1) + expect(updates[0].payload).toEqual( + expect.objectContaining({ registrationGeneration: 3, configFingerprint: 'fp-new' }) + ) + expect(work.candidates).toHaveLength(0) + }) + + it('adopts a fingerprint-identical candidate from a superseded attempt instead of reinserting', async () => { + const supersededCandidate = activeRow({ + id: 'wh-prev-candidate', + registrationStatus: 'candidate', + registrationGeneration: 2, + configFingerprint: 'fp-new', + preparedAt: null, + isActive: false, + deploymentVersionId: 'version-2', + archivedAt: new Date('2026-07-14T00:00:00Z'), + }) + const { inserts, updates } = runInTx([ + [{ id: 'workflow-1' }], + [], + [], + [supersededCandidate], + [], + ]) + + const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] }) + + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(1) + expect(updates[0].payload).toEqual( + expect.objectContaining({ registrationGeneration: 3, deploymentVersionId: 'version-3' }) + ) + expect(work.candidates).toHaveLength(1) + expect(work.orphanedCandidates).toHaveLength(0) + }) + + it('re-collects stale orphans from earlier attempts so they cannot leak forever', async () => { + const staleOrphan = activeRow({ + id: 'wh-stale-orphan', + registrationStatus: 'orphaned', + registrationGeneration: 2, + preparedAt: null, + isActive: false, + archivedAt: new Date('2026-07-13T00:00:00Z'), + }) + const { inserts } = runInTx([[{ id: 'workflow-1' }], [], [], [], [staleOrphan]]) + + const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] }) + + expect(work.orphanedCandidates).toEqual([staleOrphan]) + expect(inserts).toHaveLength(1) + }) + + it('rejects stale generations before touching rows', async () => { + mockIsDeploymentOperationCurrent.mockResolvedValue(false) + const { inserts, updates } = runInTx([[{ id: 'workflow-1' }]]) + + await expect( + prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] }) + ).rejects.toBeInstanceOf(StaleWebhookRegistrationOperationError) + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(0) + }) +}) diff --git a/apps/sim/lib/webhooks/registration-store.ts b/apps/sim/lib/webhooks/registration-store.ts new file mode 100644 index 00000000000..2754a89de94 --- /dev/null +++ b/apps/sim/lib/webhooks/registration-store.ts @@ -0,0 +1,591 @@ +import { db } from '@sim/db' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' +import type { DbOrTx } from '@sim/workflow-persistence/types' +import { and, eq, gt, inArray, isNull, lt, lte } from 'drizzle-orm' +import { claimWebhookPath } from '@/lib/webhooks/path-claims' +import { projectDesiredWebhookProviderConfig } from '@/lib/webhooks/provider-subscriptions' +import { + fingerprintDesiredWebhookRegistration, + normalizeWebhookRegistrationPath, +} from '@/lib/webhooks/registration-identity' +import { planWebhookRegistrationReconciliation } from '@/lib/webhooks/registration-reconciliation' +import type { DeploymentOperationStatus } from '@/lib/workflows/deployment-lifecycle' +import { isDeploymentOperationCurrent } from '@/lib/workflows/persistence/deployment-operations' + +export type WebhookRegistrationRow = typeof webhook.$inferSelect +export type WebhookRegistrationStatus = 'active' | 'candidate' | 'retired' | 'orphaned' + +export interface WebhookRegistrationOperationFence { + workflowId: string + operationId: string + generation: number + deploymentVersionId: string +} + +export interface DesiredWebhookRegistrationIntent { + blockId: string + provider: string + path: string | null + routingKey: string | null + providerConfig: Record + configFingerprint: string +} + +export interface PreparedWebhookCandidate { + desired: DesiredWebhookRegistrationIntent + row: WebhookRegistrationRow +} + +export interface PreparedWebhookRegistrationWork { + candidates: PreparedWebhookCandidate[] + orphanedCandidates: WebhookRegistrationRow[] +} + +export class StaleWebhookRegistrationOperationError extends Error { + readonly code = 'stale_webhook_registration_operation' + + constructor(message = 'Webhook registration operation is stale') { + super(message) + this.name = 'StaleWebhookRegistrationOperationError' + } +} + +function assertOperationGeneration(generation: number): void { + if (!Number.isSafeInteger(generation) || generation <= 0) { + throw new TypeError('Webhook registration generation must be a positive safe integer') + } +} + +async function assertCurrentOperation( + tx: DbOrTx, + fence: WebhookRegistrationOperationFence, + allowedStatuses: readonly DeploymentOperationStatus[] +): Promise { + assertOperationGeneration(fence.generation) + + const [workflowRow] = await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, fence.workflowId)) + .for('update') + if (!workflowRow) { + throw new StaleWebhookRegistrationOperationError('Webhook registration workflow is missing') + } + + const isCurrent = await isDeploymentOperationCurrent( + { + workflowId: fence.workflowId, + operationId: fence.operationId, + generation: fence.generation, + deploymentVersionId: fence.deploymentVersionId, + statuses: allowedStatuses, + }, + tx + ) + if (!isCurrent) { + throw new StaleWebhookRegistrationOperationError() + } +} + +function rowProviderConfig(row: WebhookRegistrationRow): Record { + return isPlainRecord(row.providerConfig) ? row.providerConfig : {} +} + +function rowRegistrationGeneration(row: WebhookRegistrationRow): number { + if ( + row.registrationGeneration === null || + !Number.isSafeInteger(row.registrationGeneration) || + row.registrationGeneration < 0 + ) { + throw new StaleWebhookRegistrationOperationError( + `Webhook registration ${row.id} has no valid generation` + ) + } + return row.registrationGeneration +} + +function fingerprintPersistedWebhook(row: WebhookRegistrationRow): string { + if (!row.provider) { + throw new Error(`Webhook registration ${row.id} has no provider`) + } + return fingerprintDesiredWebhookRegistration({ + provider: row.provider, + path: row.path, + routingKey: row.routingKey, + desiredConfig: projectDesiredWebhookProviderConfig(rowProviderConfig(row)), + }) +} + +async function adoptLegacyActiveRows( + tx: DbOrTx, + fence: WebhookRegistrationOperationFence +): Promise { + const [activeVersion] = await tx + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, fence.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + + if (!activeVersion) return + + const legacyRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + eq(webhook.deploymentVersionId, activeVersion.id), + isNull(webhook.registrationStatus), + eq(webhook.isActive, true), + isNull(webhook.archivedAt) + ) + ) + + const adoptedGeneration = fence.generation - 1 + for (const row of legacyRows) { + if (!row.blockId || !row.provider) continue + if (row.path) { + await claimWebhookPath(tx, { + path: row.path, + workflowId: fence.workflowId, + generation: adoptedGeneration, + }) + } + + const [adopted] = await tx + .update(webhook) + .set({ + registrationStatus: 'active', + registrationGeneration: adoptedGeneration, + configFingerprint: fingerprintPersistedWebhook(row), + preparedAt: row.updatedAt, + }) + .where(and(eq(webhook.id, row.id), isNull(webhook.registrationStatus))) + .returning({ id: webhook.id }) + + if (!adopted) { + throw new StaleWebhookRegistrationOperationError( + `Legacy webhook registration ${row.id} changed during adoption` + ) + } + } +} + +/** + * Builds the insert shape that keeps a candidate invisible to every legacy delivery query. + */ +export function buildLegacyInvisibleCandidateValues(input: { + id: string + fence: WebhookRegistrationOperationFence + desired: DesiredWebhookRegistrationIntent + now: Date +}) { + return { + id: input.id, + workflowId: input.fence.workflowId, + deploymentVersionId: input.fence.deploymentVersionId, + registrationStatus: 'candidate' as const, + registrationGeneration: input.fence.generation, + configFingerprint: input.desired.configFingerprint, + preparedAt: null, + blockId: input.desired.blockId, + path: normalizeWebhookRegistrationPath(input.desired.path), + routingKey: input.desired.routingKey, + provider: input.desired.provider, + providerConfig: input.desired.providerConfig, + isActive: false, + failedCount: 0, + archivedAt: input.now, + createdAt: input.now, + updatedAt: input.now, + } +} + +/** + * Persists one generation's registration intent before any provider call is made. + */ +export async function prepareWebhookRegistrationIntents(input: { + fence: WebhookRegistrationOperationFence + desired: readonly DesiredWebhookRegistrationIntent[] +}): Promise { + return db.transaction(async (tx) => { + await assertCurrentOperation(tx, input.fence, ['preparing']) + await adoptLegacyActiveRows(tx, input.fence) + + for (const desired of input.desired) { + if (desired.path) { + await claimWebhookPath(tx, { + path: desired.path, + workflowId: input.fence.workflowId, + generation: input.fence.generation, + }) + } + } + + const activeRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'active'), + eq(webhook.isActive, true), + isNull(webhook.archivedAt) + ) + ) + + const activeRegistrations = activeRows + .filter( + (row): row is WebhookRegistrationRow & { blockId: string } => + typeof row.blockId === 'string' + ) + .map((row) => ({ + triggerId: row.blockId, + generation: rowRegistrationGeneration(row), + fingerprint: row.configFingerprint, + row, + })) + + const plan = planWebhookRegistrationReconciliation({ + generation: input.fence.generation, + desired: input.desired.map((desired) => ({ + triggerId: desired.blockId, + fingerprint: desired.configFingerprint, + desired, + })), + existing: activeRegistrations, + }) + + const candidateRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'candidate') + ) + ) + const candidatesByBlockId = new Map( + candidateRows + .filter( + (row): row is WebhookRegistrationRow & { blockId: string } => + typeof row.blockId === 'string' + ) + .map((row) => [row.blockId, row]) + ) + + /** + * Orphans left by earlier attempts (their cleanup failed or the process + * died) are re-collected on every preparation so they cannot leak forever + * — cleanup itself stays generation-fenced, so racing operations at most + * duplicate a best-effort provider delete. + */ + const staleOrphanRows = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'orphaned') + ) + ) + + const candidates: PreparedWebhookCandidate[] = [] + const orphanedCandidates: WebhookRegistrationRow[] = [...staleOrphanRows] + const now = new Date() + + for (const action of plan.actions) { + if (action.kind === 'reuse') { + const currentGeneration = rowRegistrationGeneration(action.existing.row) + const [updated] = await tx + .update(webhook) + .set({ + registrationGeneration: input.fence.generation, + configFingerprint: action.desired.fingerprint, + preparedAt: now, + updatedAt: now, + }) + .where( + and( + eq(webhook.id, action.existing.row.id), + eq(webhook.registrationStatus, 'active'), + eq(webhook.registrationGeneration, currentGeneration), + lte(webhook.registrationGeneration, input.fence.generation) + ) + ) + .returning() + if (!updated) throw new StaleWebhookRegistrationOperationError() + continue + } + + const desired = action.desired.desired + const existingCandidate = candidatesByBlockId.get(action.triggerId) + if (existingCandidate && existingCandidate.configFingerprint === action.desired.fingerprint) { + if (existingCandidate.registrationGeneration === input.fence.generation) { + candidates.push({ desired, row: existingCandidate }) + continue + } + /** + * A fingerprint-identical candidate from a superseded attempt is + * adopted rather than orphaned and reinserted: this preserves any + * checkpointed provider progress (an external subscription it already + * created keeps serving instead of being deleted and recreated) and + * avoids insert churn against the path uniqueness index. + */ + const [adopted] = await tx + .update(webhook) + .set({ + registrationGeneration: input.fence.generation, + deploymentVersionId: input.fence.deploymentVersionId, + updatedAt: now, + }) + .where( + and( + eq(webhook.id, existingCandidate.id), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, rowRegistrationGeneration(existingCandidate)) + ) + ) + .returning() + if (!adopted) throw new StaleWebhookRegistrationOperationError() + candidates.push({ desired, row: adopted }) + continue + } + + if (existingCandidate) { + const existingGeneration = rowRegistrationGeneration(existingCandidate) + const [orphaned] = await tx + .update(webhook) + .set({ + registrationStatus: 'orphaned', + updatedAt: now, + }) + .where( + and( + eq(webhook.id, existingCandidate.id), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, existingGeneration) + ) + ) + .returning() + if (!orphaned) throw new StaleWebhookRegistrationOperationError() + orphanedCandidates.push(orphaned) + } + + const [candidate] = await tx + .insert(webhook) + .values( + buildLegacyInvisibleCandidateValues({ + id: generateShortId(), + fence: input.fence, + desired, + now, + }) + ) + .returning() + if (!candidate) throw new Error('Failed to persist webhook registration candidate') + candidates.push({ desired, row: candidate }) + } + + return { candidates, orphanedCandidates } + }) +} + +/** Checkpoints provider-managed candidate state under the operation and generation fences. */ +export async function checkpointWebhookCandidate(input: { + fence: WebhookRegistrationOperationFence + webhookId: string + providerConfig: Record + prepared?: boolean +}): Promise { + return db.transaction(async (tx) => { + await assertCurrentOperation(tx, input.fence, ['preparing']) + const now = new Date() + const [updated] = await tx + .update(webhook) + .set({ + providerConfig: input.providerConfig, + ...(input.prepared === false ? {} : { preparedAt: now }), + updatedAt: now, + }) + .where( + and( + eq(webhook.id, input.webhookId), + eq(webhook.workflowId, input.fence.workflowId), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, input.fence.generation) + ) + ) + .returning() + if (!updated) throw new StaleWebhookRegistrationOperationError() + return updated + }) +} + +/** + * Atomically promotes prepared candidates, repoints reused rows, and retires superseded rows. + * + * This is designed to be passed directly to a v2 deployment operation's activation transaction. + */ +export async function activateWebhookRegistrations( + tx: DbOrTx, + fence: WebhookRegistrationOperationFence +): Promise { + await assertCurrentOperation(tx, fence, ['active']) + + const unpreparedCandidates = await tx + .select({ id: webhook.id }) + .from(webhook) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, fence.generation), + isNull(webhook.preparedAt) + ) + ) + .limit(1) + if (unpreparedCandidates.length > 0) { + throw new Error('Webhook registration candidates are not fully prepared') + } + + const newerRows = await tx + .select({ id: webhook.id }) + .from(webhook) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + inArray(webhook.registrationStatus, ['active', 'candidate']), + gt(webhook.registrationGeneration, fence.generation) + ) + ) + .limit(1) + if (newerRows.length > 0) throw new StaleWebhookRegistrationOperationError() + + const now = new Date() + await tx + .update(webhook) + .set({ + registrationStatus: 'retired', + isActive: false, + archivedAt: now, + updatedAt: now, + }) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + eq(webhook.registrationStatus, 'active'), + lt(webhook.registrationGeneration, fence.generation) + ) + ) + + await tx + .update(webhook) + .set({ + deploymentVersionId: fence.deploymentVersionId, + isActive: true, + archivedAt: null, + updatedAt: now, + }) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + eq(webhook.registrationStatus, 'active'), + eq(webhook.registrationGeneration, fence.generation) + ) + ) + + await tx + .update(webhook) + .set({ + registrationStatus: 'active', + deploymentVersionId: fence.deploymentVersionId, + isActive: true, + archivedAt: null, + updatedAt: now, + }) + .where( + and( + eq(webhook.workflowId, fence.workflowId), + eq(webhook.registrationStatus, 'candidate'), + eq(webhook.registrationGeneration, fence.generation) + ) + ) +} + +/** Lists retired rows only while the supplied activation is still the current generation. */ +export async function listRetiredWebhookRegistrationsForCleanup( + input: WebhookRegistrationOperationFence & { limit?: number } +): Promise { + return db.transaction(async (tx) => { + await assertCurrentOperation(tx, input, ['active']) + return tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.workflowId), + eq(webhook.registrationStatus, 'retired'), + lt(webhook.registrationGeneration, input.generation) + ) + ) + .orderBy(webhook.registrationGeneration, webhook.id) + .limit(input.limit ?? 100) + }) +} + +/** + * Reloads an exact cleanup snapshot. A row advanced or reused by a newer generation is rejected. + */ +export async function getWebhookCleanupSnapshotIfCurrent(input: { + workflowId: string + webhookId: string + expectedGeneration: number + statuses: readonly WebhookRegistrationStatus[] +}): Promise { + return db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, input.workflowId), + eq(webhook.id, input.webhookId), + eq(webhook.registrationGeneration, input.expectedGeneration), + inArray(webhook.registrationStatus, input.statuses) + ) + ) + .for('update') + return row ?? null + }) +} + +/** Deletes an externally cleaned row while preserving sticky path ownership. */ +export async function deleteWebhookRegistrationAfterCleanup(input: { + workflowId: string + webhookId: string + expectedGeneration: number + statuses: readonly WebhookRegistrationStatus[] +}): Promise { + return db.transaction(async (tx) => { + const [deleted] = await tx + .delete(webhook) + .where( + and( + eq(webhook.workflowId, input.workflowId), + eq(webhook.id, input.webhookId), + eq(webhook.registrationGeneration, input.expectedGeneration), + inArray(webhook.registrationStatus, input.statuses) + ) + ) + .returning({ id: webhook.id }) + return Boolean(deleted) + }) +} diff --git a/apps/sim/lib/webhooks/utils.server.test.ts b/apps/sim/lib/webhooks/utils.server.test.ts new file mode 100644 index 00000000000..6668984a600 --- /dev/null +++ b/apps/sim/lib/webhooks/utils.server.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface Condition { + kind: string + column?: unknown + value?: unknown + conditions?: Condition[] +} + +const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() })) + +vi.mock('@sim/db', () => ({ db: { select: mockSelect } })) + +vi.mock('drizzle-orm', () => ({ + and: (...conditions: Condition[]) => ({ kind: 'and', conditions }), + eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }), + isNull: (column: unknown) => ({ kind: 'isNull', column }), +})) + +import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' + +function claimLookupChain(rows: unknown[], captureCondition?: (condition: Condition) => void) { + return { + from: vi.fn(() => ({ + where: vi.fn((condition: Condition) => { + captureCondition?.(condition) + return { limit: vi.fn().mockResolvedValue(rows) } + }), + })), + } +} + +function liveRowsChain(rows: unknown[]) { + return { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(rows), + })), + })), + } +} + +describe('findConflictingWebhookPathOwner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the claim owner while the claim holder is mid-rotation', async () => { + let claimCondition: Condition | undefined + mockSelect.mockReturnValueOnce( + claimLookupChain([{ workflowId: 'workflow-owner' }], (condition) => { + claimCondition = condition + }) + ) + + const owner = await findConflictingWebhookPathOwner({ + path: ' /leads/ ', + workflowId: 'workflow-caller', + }) + + expect(owner).toBe('workflow-owner') + expect(mockSelect).toHaveBeenCalledTimes(1) + expect(claimCondition).toEqual(expect.objectContaining({ kind: 'eq', value: 'leads' })) + }) + + it('ignores the caller-owned claim and falls through to live rows', async () => { + mockSelect + .mockReturnValueOnce(claimLookupChain([{ workflowId: 'workflow-caller' }])) + .mockReturnValueOnce(liveRowsChain([])) + + const owner = await findConflictingWebhookPathOwner({ + path: 'leads', + workflowId: 'workflow-caller', + }) + + expect(owner).toBeNull() + expect(mockSelect).toHaveBeenCalledTimes(2) + }) + + it('returns a foreign live-row owner when no claim exists', async () => { + mockSelect + .mockReturnValueOnce(claimLookupChain([])) + .mockReturnValueOnce( + liveRowsChain([{ workflowId: 'workflow-caller' }, { workflowId: 'workflow-foreign' }]) + ) + + const owner = await findConflictingWebhookPathOwner({ + path: 'leads', + workflowId: 'workflow-caller', + }) + + expect(owner).toBe('workflow-foreign') + }) + + it('skips the claim lookup entirely for empty paths', async () => { + mockSelect.mockReturnValueOnce(liveRowsChain([])) + + const owner = await findConflictingWebhookPathOwner({ + path: ' ', + workflowId: 'workflow-caller', + }) + + expect(owner).toBeNull() + expect(mockSelect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 0bff56de94e..d4b21423e8c 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,19 +1,23 @@ import { db } from '@sim/db' -import { webhook, workflow } from '@sim/db/schema' +import { webhook, webhookPathClaim, workflow } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' +import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity' /** - * Returns the id of a different workflow that already owns an active webhook on - * the given path, or `null` if the path is free or owned by `workflowId`. + * Returns the id of a different workflow that already owns the given path, or + * `null` when the path is free or owned by `workflowId`. * - * Webhook paths are user-controlled and the database only enforces uniqueness - * per deployment version, so this is the single guard against cross-tenant path - * collisions for every webhook creation path. The filter mirrors the runtime - * dispatcher (`findAllWebhooksForPath`): an active, non-archived webhook on a - * non-archived workflow — inactive or archived webhooks never receive - * deliveries, so they must not reserve a path. All matching rows are scanned so - * a same-workflow row can never mask a foreign collision. + * Ownership has two sources, checked in order: + * + * 1. `webhook_path_claim` — sticky ownership acquired by the stable + * registration protocol. Claims must be honored even while the owner's + * rows are non-deliverable candidates (mid-prepare) or mid-rotation, + * otherwise another workflow could take over the path in that window. + * 2. Live webhook rows — mirrors the runtime dispatcher + * (`findAllWebhooksForPath`): an active, non-archived webhook on a + * non-archived workflow. All matching rows are scanned so a same-workflow + * row can never mask a foreign collision. */ export async function findConflictingWebhookPathOwner(params: { path: string @@ -23,6 +27,18 @@ export async function findConflictingWebhookPathOwner(params: { const { path, workflowId, tx } = params const dbCtx = tx ?? db + const normalizedPath = normalizeWebhookRegistrationPath(path) + if (normalizedPath) { + const [claim] = await dbCtx + .select({ workflowId: webhookPathClaim.workflowId }) + .from(webhookPathClaim) + .where(eq(webhookPathClaim.path, normalizedPath)) + .limit(1) + if (claim && claim.workflowId !== workflowId) { + return claim.workflowId + } + } + const existing = await dbCtx .select({ workflowId: webhook.workflowId }) .from(webhook) diff --git a/apps/sim/lib/workflows/blocks/block-outputs.test.ts b/apps/sim/lib/workflows/blocks/block-outputs.test.ts index ccd2b22e720..070a20a07d3 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.test.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.test.ts @@ -70,4 +70,25 @@ describe('block outputs parity', () => { expect(getEffectiveBlockOutputType('agent', 'min', subBlocks, options)).toBe('number') expect(getEffectiveBlockOutputType('agent', 'max', subBlocks, options)).toBe('number') }) + + it.concurrent('surfaces start run metadata paths only when the toggle is on', () => { + const subBlocks: SubBlocks = { runMetadata: { value: true } } + const options = { triggerMode: true } + + const outputs = getEffectiveBlockOutputs('start_trigger', subBlocks, options) + const paths = getEffectiveBlockOutputPaths('start_trigger', subBlocks, options) + + expect(outputs).toHaveProperty('metadata') + expect(paths).toContain('metadata.userEmail') + expect(paths).toContain('metadata.executionType') + expect(paths).toContain('metadata.workflowId') + expect( + getEffectiveBlockOutputType('start_trigger', 'metadata.userEmail', subBlocks, options) + ).toBe('string') + + const offOutputs = getEffectiveBlockOutputs('start_trigger', {}, options) + const offPaths = getEffectiveBlockOutputPaths('start_trigger', {}, options) + expect(offOutputs).not.toHaveProperty('metadata') + expect(offPaths.some((path) => path.startsWith('metadata'))).toBe(false) + }) }) diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 15e130bdd66..aa28b50d447 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -144,6 +144,35 @@ const UNIFIED_START_OUTPUTS: OutputDefinition = { files: { type: 'file[]', description: 'User uploaded files' }, } +const START_RUN_METADATA_OUTPUT = { + type: 'json', + description: 'Trusted run metadata (server-injected)', + properties: { + userEmail: { + type: 'string', + description: 'Email of the user who invoked the run (for custom blocks, the invoking user)', + }, + workspaceId: { + type: 'string', + description: 'Workspace ID of the invoking run (for custom blocks, the invoking workspace)', + }, + workflowId: { + type: 'string', + description: 'ID of the invoking workflow (for custom blocks, the invoking workflow)', + }, + executionId: { + type: 'string', + description: 'Execution ID (child workflows share the parent execution ID)', + }, + executionType: { + type: 'string', + description: 'How the run started: manual, api, chat, mcp, copilot, table, or workflow', + }, + executionMode: { type: 'string', description: 'Execution mode: sync, stream, or async' }, + startTime: { type: 'string', description: 'ISO timestamp when the execution started' }, + }, +} as OutputFieldDefinition + function applyInputFormatFields( inputFormat: InputFormatField[], outputs: OutputDefinition @@ -188,7 +217,14 @@ function getUnifiedStartOutputs( ): OutputDefinition { const outputs = { ...UNIFIED_START_OUTPUTS } const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) - return applyInputFormatFields(normalizedInputFormat, outputs) + const withFields = applyInputFormatFields(normalizedInputFormat, outputs) + + const runMetadataValue = subBlocks?.runMetadata?.value + if (runMetadataValue === true || runMetadataValue === 'true') { + withFields.metadata = START_RUN_METADATA_OUTPUT + } + + return withFields } function getLegacyStarterOutputs( @@ -491,7 +527,7 @@ function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unk current = currentObj[part] } else if ( 'type' in currentObj && - currentObj.type === 'object' && + (currentObj.type === 'object' || currentObj.type === 'json') && 'properties' in currentObj && currentObj.properties && typeof currentObj.properties === 'object' diff --git a/apps/sim/lib/workflows/conditions.test.ts b/apps/sim/lib/workflows/conditions.test.ts new file mode 100644 index 00000000000..9aba21bd912 --- /dev/null +++ b/apps/sim/lib/workflows/conditions.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isElseConditionTitle } from '@/lib/workflows/conditions' + +describe('isElseConditionTitle', () => { + it.each(['else', 'Else', ' \t eLsE \n'])('recognizes "%s" as an else title', (title) => { + expect(isElseConditionTitle(title)).toBe(true) + }) + + it('rejects other condition titles', () => { + expect(isElseConditionTitle('else if')).toBe(false) + }) + + it('does not mutate legacy snapshot titles', () => { + const condition = { title: ' Else ' } + + isElseConditionTitle(condition.title) + + expect(condition.title).toBe(' Else ') + }) +}) diff --git a/apps/sim/lib/workflows/conditions.ts b/apps/sim/lib/workflows/conditions.ts new file mode 100644 index 00000000000..e1591fc3aa5 --- /dev/null +++ b/apps/sim/lib/workflows/conditions.ts @@ -0,0 +1,9 @@ +/** + * Returns whether a condition title represents the fallback branch. + * + * @param title - Condition title from a workflow snapshot + * @returns Whether the normalized title is `else` + */ +export function isElseConditionTitle(title: unknown): boolean { + return typeof title === 'string' && title.trim().toLowerCase() === 'else' +} diff --git a/apps/sim/lib/workflows/deployment-lifecycle.test.ts b/apps/sim/lib/workflows/deployment-lifecycle.test.ts new file mode 100644 index 00000000000..76c6dd559ce --- /dev/null +++ b/apps/sim/lib/workflows/deployment-lifecycle.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + canTransitionDeploymentOperation, + createDeploymentReadiness, + isDeploymentReadinessComplete, + parseDeploymentReadiness, + toSafeDeploymentError, +} from '@/lib/workflows/deployment-lifecycle' + +describe('deployment lifecycle', () => { + it('allows only forward in-flight transitions', () => { + expect(canTransitionDeploymentOperation('preparing', 'activating')).toBe(true) + expect(canTransitionDeploymentOperation('preparing', 'failed')).toBe(true) + expect(canTransitionDeploymentOperation('activating', 'active')).toBe(true) + expect(canTransitionDeploymentOperation('active', 'activating')).toBe(false) + expect(canTransitionDeploymentOperation('failed', 'preparing')).toBe(false) + }) + + it('tracks required component readiness without accepting malformed state', () => { + const readiness = createDeploymentReadiness( + ['webhooks', 'schedules'], + new Date('2026-07-14T08:00:00.000Z') + ) + expect(isDeploymentReadinessComplete(readiness)).toBe(false) + + readiness.webhooks.status = 'ready' + readiness.schedules.status = 'ready' + expect(isDeploymentReadinessComplete(readiness)).toBe(true) + expect(parseDeploymentReadiness(readiness)).toEqual(readiness) + expect( + parseDeploymentReadiness({ schedules: { status: 'unknown', updatedAt: 'now' } }) + ).toBeNull() + }) + + it('sanitizes persisted errors', () => { + const error = Object.assign( + new Error( + 'authorization=Bearer-secret password=hunter2 https://user:pass@example.com failed\nnext' + ), + { code: 'UPSTREAM SECRET/FAILURE' } + ) + + expect(toSafeDeploymentError(error)).toEqual({ + code: 'upstream_secret_failure', + message: + 'authorization=[redacted] password=[redacted] https://[redacted]@example.com failed next', + }) + }) + + it('drops driver bound-parameter tails that can carry credentials', () => { + const error = new Error( + 'Failed query: insert into "webhook" ("id", "provider_config") values ($1, $2)\nparams: wh-1,{"triggerApiKey":"super-secret-key"}' + ) + + expect(toSafeDeploymentError(error).message).toBe( + 'Failed query: insert into "webhook" ("id", "provider_config") values ($1, $2)' + ) + }) +}) diff --git a/apps/sim/lib/workflows/deployment-lifecycle.ts b/apps/sim/lib/workflows/deployment-lifecycle.ts new file mode 100644 index 00000000000..1ed98bf590b --- /dev/null +++ b/apps/sim/lib/workflows/deployment-lifecycle.ts @@ -0,0 +1,215 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' + +export const DEPLOYMENT_OPERATION_PROTOCOL_VERSION = 2 + +export const DEPLOYMENT_OPERATION_STATUSES = [ + 'preparing', + 'activating', + 'active', + 'failed', + 'superseded', +] as const + +export const DEPLOYMENT_OPERATION_ACTIONS = ['deploy', 'activate'] as const + +export const DEPLOYMENT_COMPONENT_STATUSES = ['pending', 'ready'] as const + +export type DeploymentOperationStatus = (typeof DEPLOYMENT_OPERATION_STATUSES)[number] +export type DeploymentOperationAction = (typeof DEPLOYMENT_OPERATION_ACTIONS)[number] +export type DeploymentComponentStatus = (typeof DEPLOYMENT_COMPONENT_STATUSES)[number] + +export interface SafeDeploymentError { + code: string + message: string +} + +export interface DeploymentComponentReadiness { + status: DeploymentComponentStatus + updatedAt: string +} + +export type DeploymentReadiness = Record + +const ALLOWED_TRANSITIONS: Readonly< + Record +> = { + preparing: ['activating', 'failed', 'superseded'], + activating: ['active', 'failed', 'superseded'], + active: [], + failed: [], + superseded: [], +} + +const MAX_ERROR_CODE_LENGTH = 64 +const MAX_ERROR_MESSAGE_LENGTH = 500 + +/** + * Narrows a persisted value to a supported operation status. + */ +export function isDeploymentOperationStatus(value: unknown): value is DeploymentOperationStatus { + return DEPLOYMENT_OPERATION_STATUSES.includes(value as DeploymentOperationStatus) +} + +/** + * Narrows a persisted value to a supported operation action. + */ +export function isDeploymentOperationAction(value: unknown): value is DeploymentOperationAction { + return DEPLOYMENT_OPERATION_ACTIONS.includes(value as DeploymentOperationAction) +} + +/** + * Returns whether an operation may move between two lifecycle states. + */ +export function canTransitionDeploymentOperation( + from: DeploymentOperationStatus, + to: DeploymentOperationStatus +): boolean { + return ALLOWED_TRANSITIONS[from].includes(to) +} + +/** + * Builds the initial readiness map for the components required by an attempt. + */ +export function createDeploymentReadiness( + components: readonly string[], + updatedAt = new Date() +): DeploymentReadiness { + const readiness: DeploymentReadiness = {} + for (const component of components) { + const normalizedComponent = component.trim() + if (!normalizedComponent) { + throw new Error('Deployment readiness component names cannot be empty') + } + if (readiness[normalizedComponent]) { + throw new Error(`Duplicate deployment readiness component: ${normalizedComponent}`) + } + readiness[normalizedComponent] = { + status: 'pending', + updatedAt: updatedAt.toISOString(), + } + } + return readiness +} + +/** + * Returns true only when every declared component is ready. + */ +export function isDeploymentReadinessComplete(readiness: DeploymentReadiness): boolean { + return Object.values(readiness).every((component) => component.status === 'ready') +} + +/** + * Narrows persisted JSON to the lifecycle readiness shape. + */ +export function parseDeploymentReadiness(value: unknown): DeploymentReadiness | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const readiness: DeploymentReadiness = {} + for (const [component, rawState] of Object.entries(value)) { + if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) return null + + const state = rawState as Record + if ( + !DEPLOYMENT_COMPONENT_STATUSES.includes(state.status as DeploymentComponentStatus) || + typeof state.updatedAt !== 'string' + ) { + return null + } + + readiness[component] = { + status: state.status as DeploymentComponentStatus, + updatedAt: state.updatedAt, + } + } + + return readiness +} + +export const DEPLOYMENT_ERROR_CODES = { + webhookPathConflict: 'webhook_path_conflict', + invalidTriggerConfiguration: 'invalid_trigger_configuration', +} as const + +const NON_RETRYABLE_DEPLOYMENT_ERROR_CODES = new Set([ + DEPLOYMENT_ERROR_CODES.webhookPathConflict, + DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration, +]) + +/** + * Returns true when a persisted deployment error code cannot succeed on a + * plain retry — the configuration must change first. Everything else + * (exhausted transient retries, generic failures) is worth redeploying as-is. + */ +export function isNonRetryableDeploymentErrorCode(code: string | null | undefined): boolean { + return code != null && NON_RETRYABLE_DEPLOYMENT_ERROR_CODES.has(code) +} + +/** + * A preparation failure that cannot succeed on retry (path conflicts, invalid + * trigger configuration). The outbox handler fails the operation immediately + * instead of burning the full retry budget. + */ +export class NonRetryableDeploymentError extends Error { + constructor( + message: string, + readonly errorCode: string = 'preparation_failed' + ) { + super(message) + this.name = 'NonRetryableDeploymentError' + } +} + +/** + * Narrows an unknown caught value to {@link NonRetryableDeploymentError}. + */ +export function isNonRetryableDeploymentError( + error: unknown +): error is NonRetryableDeploymentError { + return error instanceof NonRetryableDeploymentError +} + +/** + * Converts an unknown failure into the bounded public shape persisted on an attempt. + */ +export function toSafeDeploymentError(error: unknown, errorCode?: string): SafeDeploymentError { + const source = + errorCode ?? + (error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code ?? 'deployment_failed') + : 'deployment_failed') + const code = + truncate( + source + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '_'), + MAX_ERROR_CODE_LENGTH, + '' + ) || 'deployment_failed' + + const message = sanitizeDeploymentErrorMessage( + getErrorMessage(error, 'Deployment operation failed') + ) + return { code, message } +} + +function sanitizeDeploymentErrorMessage(message: string): string { + /** + * Driver errors ("Failed query: ...\nparams: ...") embed every bound + * parameter value, which can include user credentials from provider + * configs — the parameter tail is dropped before anything else because the + * control-character pass below would otherwise fold it into one line. + */ + const withoutBoundParams = message.split(/\nparams: /)[0] + const withoutControlCharacters = withoutBoundParams.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim() + const withoutUrlCredentials = withoutControlCharacters.replace( + /:\/\/[^/\s:@]+:[^/\s@]+@/g, + '://[redacted]@' + ) + const withoutSecrets = withoutUrlCredentials.replace( + /\b(authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|secret|password)\b(\s*[:=]\s*)([^\s,;]+)/gi, + '$1$2[redacted]' + ) + return truncate(withoutSecrets || 'Deployment operation failed', MAX_ERROR_MESSAGE_LENGTH, '') +} diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts new file mode 100644 index 00000000000..29bbc7a6a3d --- /dev/null +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -0,0 +1,514 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockLimit, + mockPrepareWebhooks, + mockGetDeploymentOperation, + mockMarkDeploymentComponentReadiness, + mockBeginDeploymentOperationActivation, + mockActivateDeploymentOperation, + mockMarkDeploymentOperationFailed, + mockRecordDeploymentOperationRetry, + mockIsDeploymentOperationCurrent, + mockIsDeploymentVersionProtectedByCurrentOperation, + mockCreateSchedulesForDeploy, + mockSyncMcpToolsForWorkflow, + mockNotifyMcpToolServers, + mockSetWorkflowMcpTransactionLockTimeout, + mockCleanupWebhooksForWorkflow, + mockActivateWebhookRegistrations, + mockCleanupRetiredWebhookRegistrations, + mockRecordAudit, + mockEmitWorkflowDeployedEvent, + mockCaptureServerEvent, + mockTx, +} = vi.hoisted(() => ({ + mockLimit: vi.fn(), + mockPrepareWebhooks: vi.fn(), + mockGetDeploymentOperation: vi.fn(), + mockMarkDeploymentComponentReadiness: vi.fn(), + mockBeginDeploymentOperationActivation: vi.fn(), + mockActivateDeploymentOperation: vi.fn(), + mockMarkDeploymentOperationFailed: vi.fn(), + mockRecordDeploymentOperationRetry: vi.fn(), + mockIsDeploymentOperationCurrent: vi.fn(), + mockIsDeploymentVersionProtectedByCurrentOperation: vi.fn(), + mockCreateSchedulesForDeploy: vi.fn(), + mockSyncMcpToolsForWorkflow: vi.fn(), + mockNotifyMcpToolServers: vi.fn(), + mockSetWorkflowMcpTransactionLockTimeout: vi.fn(), + mockCleanupWebhooksForWorkflow: vi.fn(), + mockActivateWebhookRegistrations: vi.fn(), + mockCleanupRetiredWebhookRegistrations: vi.fn(), + mockRecordAudit: vi.fn(), + mockEmitWorkflowDeployedEvent: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockTx: { select: vi.fn(), update: vi.fn(), execute: vi.fn() }, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED', + WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED', + }, + AuditResourceType: { WORKFLOW: 'WORKFLOW' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: mockLimit, + })), + })), + })), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + }, + workflow: { + id: 'workflow.id', + isDeployed: 'workflow.isDeployed', + }, + workflowDeploymentVersion: { + id: 'workflowDeploymentVersion.id', + workflowId: 'workflowDeploymentVersion.workflowId', + state: 'workflowDeploymentVersion.state', + isActive: 'workflowDeploymentVersion.isActive', + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args) => ({ type: 'and', args })), + eq: vi.fn((column, value) => ({ type: 'eq', column, value })), + ne: vi.fn((column, value) => ({ type: 'ne', column, value })), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { INTERNAL_API_SECRET: 'secret' }, +})) + +vi.mock('@/lib/core/outbox/service', () => ({ + enqueueOutboxEvent: vi.fn(), + processOutboxEventById: vi.fn(), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'request-generated', +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'http://localhost:3000', + getSocketServerUrl: () => 'http://localhost:3002', +})) + +vi.mock('@/lib/mcp/server-locks', () => ({ + setWorkflowMcpTransactionLockTimeout: mockSetWorkflowMcpTransactionLockTimeout, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + notifyMcpToolServers: mockNotifyMcpToolServers, + removeMcpToolsForWorkflow: vi.fn(), + syncMcpToolsForWorkflow: mockSyncMcpToolsForWorkflow, +})) + +vi.mock('@/lib/webhooks/deploy', () => ({ + cleanupWebhooksForWorkflow: mockCleanupWebhooksForWorkflow, + prepareStableTriggerWebhooksForDeploy: vi.fn(), + saveTriggerWebhooksForDeploy: vi.fn(), +})) + +vi.mock('@/lib/webhooks/registration-service', () => ({ + cleanupRetiredWebhookRegistrationsAfterActivation: mockCleanupRetiredWebhookRegistrations, +})) + +vi.mock('@/lib/webhooks/registration-store', () => ({ + activateWebhookRegistrations: mockActivateWebhookRegistrations, +})) + +vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ + activateDeploymentOperation: mockActivateDeploymentOperation, + beginDeploymentOperationActivation: mockBeginDeploymentOperationActivation, + getDeploymentOperation: mockGetDeploymentOperation, + isDeploymentOperationCurrent: mockIsDeploymentOperationCurrent, + isDeploymentVersionProtectedByCurrentOperation: + mockIsDeploymentVersionProtectedByCurrentOperation, + markDeploymentComponentReadiness: mockMarkDeploymentComponentReadiness, + markDeploymentOperationFailed: mockMarkDeploymentOperationFailed, + recordDeploymentOperationRetry: mockRecordDeploymentOperationRetry, +})) + +vi.mock('@/lib/workflows/schedules', () => ({ + createSchedulesForDeploy: mockCreateSchedulesForDeploy, + deleteSchedulesForWorkflow: vi.fn(), +})) + +vi.mock('@/lib/workspace-events/emitter', () => ({ + emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent, +})) + +import type { OutboxEventContext } from '@/lib/core/outbox/service' +import { NonRetryableDeploymentError } from '@/lib/workflows/deployment-lifecycle' +import { + createWorkflowDeploymentOutboxHandlers, + type PrepareDeploymentV2Payload, + WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS, +} from '@/lib/workflows/deployment-outbox' + +const NOW = new Date('2026-07-14T08:00:00.000Z') + +function operation(overrides: Record = {}) { + return { + id: 'operation-1', + workflowId: 'workflow-1', + deploymentVersionId: 'version-2', + version: 2, + previousActiveVersionId: 'version-1', + action: 'deploy' as const, + protocolVersion: 2, + generation: 2, + status: 'preparing' as const, + componentReadiness: { + webhooks: { status: 'pending', updatedAt: NOW.toISOString() }, + schedules: { status: 'pending', updatedAt: NOW.toISOString() }, + mcp: { status: 'pending', updatedAt: NOW.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-1', + requestHash: 'hash', + actorId: 'user-1', + completedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } +} + +function payload(): PrepareDeploymentV2Payload { + return { + protocolVersion: 2, + operationId: 'operation-1', + generation: 2, + workflowId: 'workflow-1', + deploymentVersionId: 'version-2', + version: 2, + userId: 'user-1', + requestId: 'request-1', + checkpoints: {}, + } +} + +function context(controller = new AbortController(), attempts = 0): OutboxEventContext { + return { + eventId: 'event-1', + eventType: WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2, + attempts, + maxAttempts: 4, + signal: controller.signal, + checkpointPayload: vi.fn().mockResolvedValue(undefined), + } +} + +function handler() { + return createWorkflowDeploymentOutboxHandlers({ + prepareWebhooks: mockPrepareWebhooks, + })[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2] +} + +describe('versioned deployment preparation outbox', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + mockPrepareWebhooks.mockResolvedValue(undefined) + mockActivateWebhookRegistrations.mockResolvedValue(undefined) + mockCleanupRetiredWebhookRegistrations.mockResolvedValue(undefined) + mockCreateSchedulesForDeploy.mockResolvedValue({ success: true }) + mockSyncMcpToolsForWorkflow.mockResolvedValue([{ serverId: 'mcp-server-1' }]) + mockSetWorkflowMcpTransactionLockTimeout.mockResolvedValue(undefined) + mockEmitWorkflowDeployedEvent.mockResolvedValue(undefined) + mockMarkDeploymentOperationFailed.mockResolvedValue({ + success: true, + operation: operation({ status: 'failed' }), + }) + mockIsDeploymentOperationCurrent.mockResolvedValue(false) + mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(false) + }) + + it('activates only after every preparation component is ready', async () => { + const preparing = operation() + const webhooksReady = operation({ + componentReadiness: { + ...preparing.componentReadiness, + webhooks: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + const schedulesReady = operation({ + componentReadiness: { + ...webhooksReady.componentReadiness, + schedules: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + const allReady = operation({ + componentReadiness: { + ...schedulesReady.componentReadiness, + mcp: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + const activating = operation({ + status: 'activating', + componentReadiness: allReady.componentReadiness, + }) + const active = operation({ + status: 'active', + componentReadiness: allReady.componentReadiness, + completedAt: NOW, + }) + mockGetDeploymentOperation.mockResolvedValue(preparing) + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + mockMarkDeploymentComponentReadiness + .mockResolvedValueOnce({ success: true, operation: webhooksReady }) + .mockResolvedValueOnce({ success: true, operation: schedulesReady }) + .mockResolvedValueOnce({ success: true, operation: allReady }) + mockBeginDeploymentOperationActivation.mockResolvedValue({ + success: true, + operation: activating, + }) + mockActivateDeploymentOperation.mockImplementation(async (input) => { + await input.onActivateTransaction?.(mockTx, active) + return { success: true, operation: active } + }) + + await handler()(payload(), context()) + + expect(mockPrepareWebhooks).toHaveBeenCalledTimes(1) + expect(mockCreateSchedulesForDeploy).toHaveBeenCalledWith( + 'workflow-1', + {}, + undefined, + 'version-2', + 'operation-1' + ) + expect( + mockMarkDeploymentComponentReadiness.mock.calls.map(([input]) => input.component) + ).toEqual(['webhooks', 'schedules', 'mcp']) + expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + tx: mockTx, + notify: false, + state: { blocks: {} }, + }) + ) + expect(mockActivateWebhookRegistrations).toHaveBeenCalledWith(mockTx, { + workflowId: 'workflow-1', + operationId: 'operation-1', + generation: 2, + deploymentVersionId: 'version-2', + }) + expect(mockCleanupRetiredWebhookRegistrations).toHaveBeenCalledTimes(1) + expect(mockNotifyMcpToolServers).toHaveBeenCalledWith([{ serverId: 'mcp-server-1' }]) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_deployed', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, + expect.objectContaining({ + groups: { workspace: 'workspace-1' }, + setOnce: expect.objectContaining({ first_workflow_deployed_at: expect.any(String) }), + }) + ) + expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1) + expect(mockRecordAudit.mock.invocationCallOrder[0]).toBeGreaterThan( + mockActivateDeploymentOperation.mock.invocationCallOrder[0] + ) + }) + + it('ignores a superseded generation without preparing side effects', async () => { + mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'superseded' })) + + await handler()(payload(), context()) + + expect(mockPrepareWebhooks).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + expect(mockMarkDeploymentComponentReadiness).not.toHaveBeenCalled() + expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() + }) + + it('honors an aborted signal before starting any side effect', async () => { + const controller = new AbortController() + controller.abort() + + await expect(handler()(payload(), context(controller))).rejects.toMatchObject({ + name: 'AbortError', + }) + + expect(mockGetDeploymentOperation).not.toHaveBeenCalled() + expect(mockPrepareWebhooks).not.toHaveBeenCalled() + }) + + it('generation-guards failure on the final outbox attempt', async () => { + const preparing = operation() + mockGetDeploymentOperation.mockResolvedValue(preparing) + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + mockPrepareWebhooks.mockRejectedValue(new Error('provider unavailable')) + + await expect(handler()(payload(), context(new AbortController(), 3))).rejects.toThrow( + 'provider unavailable' + ) + + expect(mockMarkDeploymentOperationFailed).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + operationId: 'operation-1', + generation: 2, + error: expect.objectContaining({ message: 'provider unavailable' }), + errorCode: 'preparation_failed', + }) + expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() + }) + + it('retries transient mid-attempt failures without failing the operation', async () => { + const preparing = operation() + mockGetDeploymentOperation.mockResolvedValue(preparing) + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + mockPrepareWebhooks.mockRejectedValue(new Error('provider briefly unavailable')) + + await expect(handler()(payload(), context(new AbortController(), 0))).rejects.toThrow( + 'provider briefly unavailable' + ) + + expect(mockMarkDeploymentOperationFailed).not.toHaveBeenCalled() + expect(mockRecordDeploymentOperationRetry).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + operationId: 'operation-1', + generation: 2, + error: expect.objectContaining({ message: 'provider briefly unavailable' }), + }) + expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() + }) + + it('skips checkpointed webhook preparation on resume without re-running provider work', async () => { + const preparing = operation() + const webhooksReady = operation({ + componentReadiness: { + ...preparing.componentReadiness, + webhooks: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + const schedulesReady = operation({ + componentReadiness: { + ...webhooksReady.componentReadiness, + schedules: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + const allReady = operation({ + componentReadiness: { + ...schedulesReady.componentReadiness, + mcp: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + mockGetDeploymentOperation.mockResolvedValue(preparing) + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + mockMarkDeploymentComponentReadiness + .mockResolvedValueOnce({ success: true, operation: webhooksReady }) + .mockResolvedValueOnce({ success: true, operation: schedulesReady }) + .mockResolvedValueOnce({ success: true, operation: allReady }) + mockBeginDeploymentOperationActivation.mockResolvedValue({ + success: true, + operation: operation({ + status: 'activating', + componentReadiness: allReady.componentReadiness, + }), + }) + mockActivateDeploymentOperation.mockResolvedValue({ + success: true, + operation: operation({ + status: 'active', + componentReadiness: allReady.componentReadiness, + completedAt: NOW, + }), + }) + + const resumedPayload = { ...payload(), checkpoints: { webhooksPrepared: true } } + await handler()(resumedPayload, context()) + + expect(mockPrepareWebhooks).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).toHaveBeenCalledTimes(1) + expect(mockMarkDeploymentComponentReadiness.mock.calls[0][0]).toEqual( + expect.objectContaining({ component: 'webhooks', status: 'ready' }) + ) + }) + + it('fails the operation immediately on a non-retryable preparation error', async () => { + const preparing = operation() + mockGetDeploymentOperation.mockResolvedValue(preparing) + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }]) + mockPrepareWebhooks.mockRejectedValue( + new NonRetryableDeploymentError( + 'Webhook path "/leads" is already in use. Choose a different path.', + 'webhook_path_conflict' + ) + ) + + await expect(handler()(payload(), context())).resolves.toBeUndefined() + + expect(mockMarkDeploymentOperationFailed).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + operationId: 'operation-1', + generation: 2, + error: expect.objectContaining({ + message: 'Webhook path "/leads" is already in use. Choose a different path.', + }), + errorCode: 'webhook_path_conflict', + }) + expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() + }) + + it('keeps v1 cleanup from deleting a candidate owned by the current v2 operation', async () => { + mockLimit + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([{ isActive: false }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(true) + const cleanupHandler = + createWorkflowDeploymentOutboxHandlers()[ + WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS + ] + + await cleanupHandler( + { + workflowId: 'workflow-1', + deploymentVersionIds: ['version-2'], + userId: 'user-1', + requestId: 'request-1', + }, + context() + ) + + expect(mockCleanupWebhooksForWorkflow).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index 3c4ce5e9952..59dbf525f88 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -1,33 +1,123 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { and, eq, ne } from 'drizzle-orm' import { NextRequest } from 'next/server' +import { env } from '@/lib/core/config/env' import { enqueueOutboxEvent, + type OutboxEventContext, + type OutboxHandler, type OutboxHandlerRegistry, type ProcessSingleOutboxResult, processOutboxEventById, } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBaseUrl, getSocketServerUrl } from '@/lib/core/utils/urls' import { setWorkflowMcpTransactionLockTimeout } from '@/lib/mcp/server-locks' import { notifyMcpToolServers, removeMcpToolsForWorkflow, syncMcpToolsForWorkflow, } from '@/lib/mcp/workflow-mcp-sync' -import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' +import { captureServerEvent } from '@/lib/posthog/server' +import { + cleanupWebhooksForWorkflow, + prepareStableTriggerWebhooksForDeploy, + saveTriggerWebhooksForDeploy, +} from '@/lib/webhooks/deploy' +import { cleanupRetiredWebhookRegistrationsAfterActivation } from '@/lib/webhooks/registration-service' +import { activateWebhookRegistrations } from '@/lib/webhooks/registration-store' +import { + DEPLOYMENT_ERROR_CODES, + DEPLOYMENT_OPERATION_PROTOCOL_VERSION, + type DeploymentOperationStatus, + isDeploymentReadinessComplete, + isNonRetryableDeploymentError, + NonRetryableDeploymentError, + parseDeploymentReadiness, +} from '@/lib/workflows/deployment-lifecycle' +import { + activateDeploymentOperation, + beginDeploymentOperationActivation, + type DeploymentOperationGeneration, + getDeploymentOperation, + isDeploymentOperationCurrent, + isDeploymentVersionProtectedByCurrentOperation, + markDeploymentComponentReadiness, + markDeploymentOperationFailed, + recordDeploymentOperationRetry, + type WorkflowDeploymentOperation, +} from '@/lib/workflows/persistence/deployment-operations' import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from '@/lib/workflows/schedules' +import { emitWorkflowDeployedEvent } from '@/lib/workspace-events/emitter' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDeploymentOutbox') export const WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS = { + PREPARE_V2: 'workflow.deployment.prepare.v2', + /** One-release rolling compatibility for events admitted by pre-v2 pods. */ SYNC_ACTIVE_SIDE_EFFECTS: 'workflow.deployment.sync-active-side-effects', + /** One-release rolling compatibility for cleanup admitted by pre-v2 pods. */ CLEANUP_INACTIVE_SIDE_EFFECTS: 'workflow.deployment.cleanup-inactive-side-effects', CLEANUP_UNDEPLOYED_SIDE_EFFECTS: 'workflow.deployment.cleanup-undeployed-side-effects', } as const +export const DEPLOYMENT_READINESS_COMPONENTS = ['webhooks', 'schedules', 'mcp'] as const + +/** + * One inline attempt at deploy time plus three exponential-backoff retries. + * Checkpoints make retries resumable, so a persistently failing preparation + * reaches its terminal failed state within roughly half a minute instead of + * burning a long retry tail while the UI shows retrying. + */ +const DEPLOYMENT_PREPARATION_MAX_ATTEMPTS = 4 + +interface DeploymentPreparationCheckpoints { + webhooksPrepared?: boolean + schedulesPrepared?: boolean + mcpReadyForActivation?: boolean + inactiveCleanupCompleted?: boolean + auditEmitted?: boolean + analyticsCaptured?: boolean + socketNotified?: boolean + workspaceEventEmitted?: boolean +} + +interface DeploymentCleanupOperationFence extends DeploymentOperationGeneration { + deploymentVersionId: string + statuses: readonly DeploymentOperationStatus[] +} + +export interface PrepareDeploymentV2Payload { + protocolVersion: number + operationId: string + generation: number + workflowId: string + deploymentVersionId: string + version: number + userId: string + requestId: string + checkpoints: DeploymentPreparationCheckpoints +} + +export interface PrepareDeploymentWebhooksInput { + request: NextRequest + workflowId: string + workflow: Record + userId: string + blocks: Record + requestId: string + deploymentVersionId: string + operationId: string + generation: number + signal: AbortSignal +} + +export type PrepareDeploymentWebhooksHook = (input: PrepareDeploymentWebhooksInput) => Promise + interface SyncActiveSideEffectsPayload { workflowId: string deploymentVersionId: string @@ -50,16 +140,13 @@ interface CleanupInactiveSideEffectsPayload { requestId?: string } -export async function enqueueWorkflowDeploymentSideEffects( +export async function enqueueWorkflowDeploymentPreparation( executor: Pick, - payload: SyncActiveSideEffectsPayload + payload: PrepareDeploymentV2Payload ): Promise { - return enqueueOutboxEvent( - executor, - WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS, - payload, - { maxAttempts: 10 } - ) + return enqueueOutboxEvent(executor, WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2, payload, { + maxAttempts: DEPLOYMENT_PREPARATION_MAX_ATTEMPTS, + }) } export async function enqueueWorkflowUndeploySideEffects( @@ -92,6 +179,532 @@ export async function processWorkflowDeploymentOutboxEvent( return processOutboxEventById(eventId, workflowDeploymentOutboxHandlers) } +/** + * Notifies connected clients after deployment compatibility state changes. + */ +export async function notifySocketDeploymentChanged( + workflowId: string, + options: { signal?: AbortSignal; throwOnError?: boolean } = {} +): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-deployed`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId }), + signal: options.signal, + }) + if (!response.ok) { + const error = new Error( + `Socket deployment notification failed (${response.status}) for workflow ${workflowId}` + ) + if (options.throwOnError) throw error + logger.warn(error.message) + } + } catch (error) { + if (options.throwOnError) throw error + logger.error('Error sending workflow deployed event to socket server', error) + } +} + +const defaultPrepareDeploymentWebhooks: PrepareDeploymentWebhooksHook = async (input) => { + input.signal.throwIfAborted() + const result = await prepareStableTriggerWebhooksForDeploy({ + request: input.request, + workflowId: input.workflowId, + workflow: input.workflow, + userId: input.userId, + blocks: input.blocks, + requestId: input.requestId, + deploymentVersionId: input.deploymentVersionId, + operationId: input.operationId, + generation: input.generation, + signal: input.signal, + }) + input.signal.throwIfAborted() + if (!result.success) { + const message = result.error?.message || 'Failed to prepare trigger configuration' + const status = result.error?.status ?? 500 + if (status >= 400 && status < 500) { + throw new NonRetryableDeploymentError( + message, + status === 409 + ? DEPLOYMENT_ERROR_CODES.webhookPathConflict + : DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration + ) + } + throw new Error(message) + } +} + +function createPrepareDeploymentHandler( + prepareWebhooks: PrepareDeploymentWebhooksHook +): OutboxHandler { + return async (rawPayload, context) => { + const payload = parsePrepareDeploymentV2Payload(rawPayload) + try { + await prepareDeploymentOperation(payload, context, prepareWebhooks) + } catch (error) { + const isFinalAttempt = context.attempts + 1 >= context.maxAttempts + if (isNonRetryableDeploymentError(error) || isFinalAttempt) { + const operation = await getDeploymentOperation(payload) + if (operation?.status === 'preparing' || operation?.status === 'activating') { + await markDeploymentOperationFailed({ + workflowId: payload.workflowId, + operationId: payload.operationId, + generation: payload.generation, + error, + errorCode: isNonRetryableDeploymentError(error) + ? error.errorCode + : 'preparation_failed', + }) + } + if (isNonRetryableDeploymentError(error)) { + logger.warn('Deployment preparation failed permanently; not retrying', { + workflowId: payload.workflowId, + operationId: payload.operationId, + error: error.message, + }) + return + } + throw error + } + + try { + /** + * Transient failure with retries remaining: surface the live error on + * the in-flight operation so status consumers show "retrying" instead + * of a blank pending state. Best-effort — the outbox retry is the + * durable mechanism, not this record. + */ + await recordDeploymentOperationRetry({ + workflowId: payload.workflowId, + operationId: payload.operationId, + generation: payload.generation, + error, + }) + } catch (recordError) { + logger.warn('Failed to record deployment retry state', { + workflowId: payload.workflowId, + operationId: payload.operationId, + error: toError(recordError).message, + }) + } + throw error + } + } +} + +async function prepareDeploymentOperation( + payload: PrepareDeploymentV2Payload, + context: OutboxEventContext, + prepareWebhooks: PrepareDeploymentWebhooksHook +): Promise { + context.signal.throwIfAborted() + let operation = await getDeploymentOperation(payload) + context.signal.throwIfAborted() + if (!operation || isTerminalNonActiveOperation(operation)) return + assertPreparationPayloadMatchesOperation(payload, operation) + + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, payload.workflowId)) + .limit(1) + context.signal.throwIfAborted() + if (!workflowRecord) throw new Error('Workflow missing during deployment preparation') + + const checkpoints = { ...payload.checkpoints } + const checkpoint = async (patch: Partial) => { + Object.assign(checkpoints, patch) + context.signal.throwIfAborted() + await context.checkpointPayload({ checkpoints }) + context.signal.throwIfAborted() + } + + if (operation.status === 'active') { + await cleanupRetiredWebhooksForOperation({ + payload, + workflow: workflowRecord as Record, + context, + }) + await cleanupInactiveDeploymentsForOperation({ + payload, + workflow: workflowRecord as Record, + checkpoints, + checkpoint, + context, + }) + await emitPostActivationSideEffects({ + payload, + operation, + workflow: workflowRecord as Record, + checkpoints, + checkpoint, + context, + }) + return + } + if (operation.status !== 'preparing' && operation.status !== 'activating') return + + const [versionRow] = await db + .select({ + id: workflowDeploymentVersion.id, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, payload.workflowId), + eq(workflowDeploymentVersion.id, payload.deploymentVersionId) + ) + ) + .limit(1) + context.signal.throwIfAborted() + if (!versionRow?.state) throw new Error('Deployment version missing during preparation') + + const state = versionRow.state as { blocks?: Record } + const blocks = state.blocks + if (!blocks || typeof blocks !== 'object') { + throw new Error('Invalid deployed state structure') + } + + operation = await prepareReadinessComponent({ + payload, + operation, + component: 'webhooks', + checkpointKey: 'webhooksPrepared', + checkpoints, + checkpoint, + context, + prepare: async () => { + await prepareWebhooks({ + request: new NextRequest(new URL('/api/webhooks', getBaseUrl())), + workflowId: payload.workflowId, + workflow: workflowRecord as Record, + userId: payload.userId, + blocks, + requestId: payload.requestId, + deploymentVersionId: payload.deploymentVersionId, + operationId: payload.operationId, + generation: payload.generation, + signal: context.signal, + }) + }, + }) + if (!operation) return + + operation = await prepareReadinessComponent({ + payload, + operation, + component: 'schedules', + checkpointKey: 'schedulesPrepared', + checkpoints, + checkpoint, + context, + prepare: async () => { + const result = await createSchedulesForDeploy( + payload.workflowId, + blocks, + undefined, + payload.deploymentVersionId, + payload.operationId + ) + if (!result.success) { + throw new Error(result.error || 'Failed to prepare schedules') + } + }, + }) + if (!operation) return + + operation = await prepareReadinessComponent({ + payload, + operation, + component: 'mcp', + checkpointKey: 'mcpReadyForActivation', + checkpoints, + checkpoint, + context, + prepare: async () => {}, + }) + if (!operation) return + + const readiness = parseDeploymentReadiness(operation.componentReadiness) + if (!readiness || !isDeploymentReadinessComplete(readiness)) return + + if (operation.status === 'preparing') { + context.signal.throwIfAborted() + const activating = await beginDeploymentOperationActivation(payload) + context.signal.throwIfAborted() + if (!activating.success) { + if (activating.reason === 'stale_generation' || activating.reason === 'invalid_transition') { + return + } + throw new Error(activating.error) + } + operation = activating.operation + } + if (operation.status !== 'activating') return + + let affectedMcpServers: Array<{ serverId: string }> = [] + context.signal.throwIfAborted() + const activated = await activateDeploymentOperation({ + workflowId: payload.workflowId, + operationId: payload.operationId, + generation: payload.generation, + onActivateTransaction: async (tx) => { + context.signal.throwIfAborted() + await activateWebhookRegistrations(tx, { + workflowId: payload.workflowId, + operationId: payload.operationId, + generation: payload.generation, + deploymentVersionId: payload.deploymentVersionId, + }) + context.signal.throwIfAborted() + await setWorkflowMcpTransactionLockTimeout(tx) + context.signal.throwIfAborted() + affectedMcpServers = await syncMcpToolsForWorkflow({ + workflowId: payload.workflowId, + requestId: payload.requestId, + state, + context: 'deployment-activation', + tx, + notify: false, + throwOnError: true, + }) + context.signal.throwIfAborted() + }, + }) + context.signal.throwIfAborted() + if (!activated.success) { + if (activated.reason === 'stale_generation' || activated.reason === 'invalid_transition') return + throw new Error(activated.error) + } + + operation = activated.operation + notifyMcpToolServers(affectedMcpServers) + context.signal.throwIfAborted() + + await cleanupRetiredWebhooksForOperation({ + payload, + workflow: workflowRecord as Record, + context, + }) + await cleanupInactiveDeploymentsForOperation({ + payload, + workflow: workflowRecord as Record, + checkpoints, + checkpoint, + context, + }) + await emitPostActivationSideEffects({ + payload, + operation, + workflow: workflowRecord as Record, + checkpoints, + checkpoint, + context, + }) +} + +async function prepareReadinessComponent(params: { + payload: PrepareDeploymentV2Payload + operation: WorkflowDeploymentOperation + component: (typeof DEPLOYMENT_READINESS_COMPONENTS)[number] + checkpointKey: keyof DeploymentPreparationCheckpoints + checkpoints: DeploymentPreparationCheckpoints + checkpoint: (patch: Partial) => Promise + context: OutboxEventContext + prepare: () => Promise +}): Promise { + const readiness = parseDeploymentReadiness(params.operation.componentReadiness) + if (readiness?.[params.component]?.status === 'ready') { + if (!params.checkpoints[params.checkpointKey]) { + await params.checkpoint({ [params.checkpointKey]: true }) + } + return params.operation + } + + if (!params.checkpoints[params.checkpointKey]) { + params.context.signal.throwIfAborted() + await params.prepare() + params.context.signal.throwIfAborted() + await params.checkpoint({ [params.checkpointKey]: true }) + } + + params.context.signal.throwIfAborted() + const result = await markDeploymentComponentReadiness({ + workflowId: params.payload.workflowId, + operationId: params.payload.operationId, + generation: params.payload.generation, + component: params.component, + status: 'ready', + expectedStatus: 'pending', + }) + params.context.signal.throwIfAborted() + if (result.success) return result.operation + if (result.reason === 'stale_generation' || result.reason === 'invalid_transition') return null + throw new Error(result.error) +} + +async function cleanupRetiredWebhooksForOperation(params: { + payload: PrepareDeploymentV2Payload + workflow: Record + context: OutboxEventContext +}): Promise { + params.context.signal.throwIfAborted() + await cleanupRetiredWebhookRegistrationsAfterActivation({ + fence: { + workflowId: params.payload.workflowId, + operationId: params.payload.operationId, + generation: params.payload.generation, + deploymentVersionId: params.payload.deploymentVersionId, + }, + workflow: params.workflow, + requestId: params.payload.requestId, + signal: params.context.signal, + }) +} + +async function cleanupInactiveDeploymentsForOperation(params: { + payload: PrepareDeploymentV2Payload + workflow: Record + checkpoints: DeploymentPreparationCheckpoints + checkpoint: (patch: Partial) => Promise + context: OutboxEventContext +}): Promise { + if (params.checkpoints.inactiveCleanupCompleted) return + const operationFence = { + workflowId: params.payload.workflowId, + operationId: params.payload.operationId, + generation: params.payload.generation, + deploymentVersionId: params.payload.deploymentVersionId, + statuses: ['active'] as const, + } + const shouldContinue = async () => { + params.context.signal.throwIfAborted() + const isCurrent = await isDeploymentOperationCurrent(operationFence) + params.context.signal.throwIfAborted() + return isCurrent + } + + if (!(await shouldContinue())) return + await cleanupInactiveDeploymentVersions({ + workflowId: params.payload.workflowId, + activeDeploymentVersionId: params.payload.deploymentVersionId, + workflow: params.workflow, + userId: params.payload.userId, + requestId: params.payload.requestId, + shouldContinue, + operationFence, + }) + if (!(await shouldContinue())) return + await params.checkpoint({ inactiveCleanupCompleted: true }) +} + +async function emitPostActivationSideEffects(params: { + payload: PrepareDeploymentV2Payload + operation: WorkflowDeploymentOperation + workflow: Record + checkpoints: DeploymentPreparationCheckpoints + checkpoint: (patch: Partial) => Promise + context: OutboxEventContext +}): Promise { + if (!params.checkpoints.auditEmitted) { + params.context.signal.throwIfAborted() + const isVersionActivation = params.operation.action === 'activate' + recordAudit({ + workspaceId: (params.workflow.workspaceId as string) || null, + actorId: params.operation.actorId, + action: isVersionActivation + ? AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED + : AuditAction.WORKFLOW_DEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: params.payload.workflowId, + resourceName: (params.workflow.name as string) || undefined, + description: isVersionActivation + ? `Activated deployment version ${params.payload.version}` + : `Deployed workflow "${(params.workflow.name as string) || params.payload.workflowId}"`, + metadata: { + deploymentVersionId: params.payload.deploymentVersionId, + version: params.payload.version, + previousVersionId: params.operation.previousActiveVersionId || undefined, + }, + }) + params.context.signal.throwIfAborted() + await params.checkpoint({ auditEmitted: true }) + } + + if (!params.checkpoints.analyticsCaptured) { + params.context.signal.throwIfAborted() + const workspaceId = (params.workflow.workspaceId as string) || '' + const isVersionActivation = params.operation.action === 'activate' + captureServerEvent( + params.payload.userId, + isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', + { + workflow_id: params.payload.workflowId, + workspace_id: workspaceId, + ...(isVersionActivation ? { version: params.payload.version } : {}), + }, + { + groups: workspaceId ? { workspace: workspaceId } : undefined, + ...(isVersionActivation + ? {} + : { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }), + } + ) + await params.checkpoint({ analyticsCaptured: true }) + } + + if (!params.checkpoints.socketNotified) { + params.context.signal.throwIfAborted() + await notifySocketDeploymentChanged(params.payload.workflowId, { + signal: params.context.signal, + throwOnError: true, + }) + params.context.signal.throwIfAborted() + await params.checkpoint({ socketNotified: true }) + } + + const workspaceId = params.workflow.workspaceId as string | null + if (workspaceId && !params.checkpoints.workspaceEventEmitted) { + params.context.signal.throwIfAborted() + await emitWorkflowDeployedEvent({ + workflowId: params.payload.workflowId, + workflowName: (params.workflow.name as string) || params.payload.workflowId, + workspaceId, + version: params.payload.version, + }) + params.context.signal.throwIfAborted() + await params.checkpoint({ workspaceEventEmitted: true }) + } +} + +function isTerminalNonActiveOperation(operation: WorkflowDeploymentOperation): boolean { + return operation.status === 'failed' || operation.status === 'superseded' +} + +function assertPreparationPayloadMatchesOperation( + payload: PrepareDeploymentV2Payload, + operation: WorkflowDeploymentOperation +): void { + if ( + payload.protocolVersion !== DEPLOYMENT_OPERATION_PROTOCOL_VERSION || + operation.protocolVersion !== payload.protocolVersion + ) { + throw new Error(`Unsupported deployment preparation protocol ${payload.protocolVersion}`) + } + if ( + operation.deploymentVersionId !== payload.deploymentVersionId || + operation.version !== payload.version + ) { + throw new Error('Deployment preparation payload does not match its operation') + } +} + const syncActiveSideEffects = async (rawPayload: unknown): Promise => { const payload = parseSyncActiveSideEffectsPayload(rawPayload) const requestId = payload.requestId ?? generateRequestId() @@ -319,7 +932,10 @@ async function cleanupInactiveDeploymentVersions(params: { workflow: Record userId: string requestId: string + shouldContinue?: () => Promise + operationFence?: DeploymentCleanupOperationFence }): Promise { + if (params.shouldContinue && !(await params.shouldContinue())) return const inactiveVersions = await db .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) @@ -332,12 +948,18 @@ async function cleanupInactiveDeploymentVersions(params: { ) for (const version of inactiveVersions) { + if (params.shouldContinue && !(await params.shouldContinue())) return + if (await isDeploymentVersionProtectedByCurrentOperation(params.workflowId, version.id)) { + continue + } await cleanupDeploymentVersionIfInactive({ workflowId: params.workflowId, workflow: params.workflow, userId: params.userId, requestId: params.requestId, deploymentVersionId: version.id, + shouldContinue: params.shouldContinue, + operationFence: params.operationFence, }) } } @@ -348,20 +970,32 @@ async function cleanupDeploymentVersionIfInactive(params: { workflow: Record userId: string requestId: string + shouldContinue?: () => Promise + operationFence?: DeploymentCleanupOperationFence }): Promise { - if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) { - await enqueueWorkflowDeploymentSideEffects(db, { - workflowId: params.workflowId, - deploymentVersionId: params.deploymentVersionId, - userId: params.userId, - requestId: params.requestId, - forceRecreateSubscriptions: true, - }) + if (params.shouldContinue && !(await params.shouldContinue())) return + if ( + await isDeploymentVersionProtectedByCurrentOperation( + params.workflowId, + params.deploymentVersionId + ) + ) { return } - - const isStillInactive = async () => - !(await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) + if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) return + + const isStillInactive = async () => { + if (params.shouldContinue && !(await params.shouldContinue())) return false + if ( + await isDeploymentVersionProtectedByCurrentOperation( + params.workflowId, + params.deploymentVersionId + ) + ) { + return false + } + return !(await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) + } await cleanupWebhooksForWorkflow( params.workflowId, @@ -373,50 +1007,39 @@ async function cleanupDeploymentVersionIfInactive(params: { isStillInactive ) - if (!(await isStillInactive())) { - await enqueueWorkflowDeploymentSideEffects(db, { - workflowId: params.workflowId, - deploymentVersionId: params.deploymentVersionId, - userId: params.userId, - requestId: params.requestId, - forceRecreateSubscriptions: true, - }) - return - } + if (!(await isStillInactive())) return - const deletedSchedules = await deleteSchedulesForDeploymentIfInactive({ + await deleteSchedulesForDeploymentIfInactive({ workflowId: params.workflowId, deploymentVersionId: params.deploymentVersionId, + operationFence: params.operationFence, }) - if (!deletedSchedules) { - if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) { - await enqueueWorkflowDeploymentSideEffects(db, { - workflowId: params.workflowId, - deploymentVersionId: params.deploymentVersionId, - userId: params.userId, - requestId: params.requestId, - forceRecreateSubscriptions: true, - }) - } - return - } - - if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) { - await enqueueWorkflowDeploymentSideEffects(db, { - workflowId: params.workflowId, - deploymentVersionId: params.deploymentVersionId, - userId: params.userId, - requestId: params.requestId, - forceRecreateSubscriptions: true, - }) - } } async function deleteSchedulesForDeploymentIfInactive(params: { workflowId: string deploymentVersionId: string + operationFence?: DeploymentCleanupOperationFence }): Promise { return db.transaction(async (tx) => { + await tx + .select({ id: workflowTable.id }) + .from(workflowTable) + .where(eq(workflowTable.id, params.workflowId)) + .for('update') + if (params.operationFence && !(await isDeploymentOperationCurrent(params.operationFence, tx))) { + return false + } + if ( + await isDeploymentVersionProtectedByCurrentOperation( + params.workflowId, + params.deploymentVersionId, + tx + ) + ) { + return false + } + const [versionRow] = await tx .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) @@ -678,6 +1301,46 @@ function parseSyncActiveSideEffectsPayload(payload: unknown): SyncActiveSideEffe return { workflowId, deploymentVersionId, userId, requestId, forceRecreateSubscriptions } } +function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2Payload { + const record = parsePayloadRecord(payload) + const protocolVersion = parseRequiredPositiveInteger(record.protocolVersion, 'protocolVersion') + const operationId = parseRequiredString(record.operationId, 'operationId') + const generation = parseRequiredPositiveInteger(record.generation, 'generation') + const workflowId = parseRequiredString(record.workflowId, 'workflowId') + const deploymentVersionId = parseRequiredString(record.deploymentVersionId, 'deploymentVersionId') + const version = parseRequiredPositiveInteger(record.version, 'version') + const userId = parseRequiredString(record.userId, 'userId') + const requestId = parseRequiredString(record.requestId, 'requestId') + const checkpoints = parseDeploymentPreparationCheckpoints(record.checkpoints) + + return { + protocolVersion, + operationId, + generation, + workflowId, + deploymentVersionId, + version, + userId, + requestId, + checkpoints, + } +} + +function parseDeploymentPreparationCheckpoints(value: unknown): DeploymentPreparationCheckpoints { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const record = value as Record + return { + ...(record.webhooksPrepared === true ? { webhooksPrepared: true } : {}), + ...(record.schedulesPrepared === true ? { schedulesPrepared: true } : {}), + ...(record.mcpReadyForActivation === true ? { mcpReadyForActivation: true } : {}), + ...(record.inactiveCleanupCompleted === true ? { inactiveCleanupCompleted: true } : {}), + ...(record.auditEmitted === true ? { auditEmitted: true } : {}), + ...(record.analyticsCaptured === true ? { analyticsCaptured: true } : {}), + ...(record.socketNotified === true ? { socketNotified: true } : {}), + ...(record.workspaceEventEmitted === true ? { workspaceEventEmitted: true } : {}), + } +} + function parseCleanupUndeployedSideEffectsPayload( payload: unknown ): CleanupUndeployedSideEffectsPayload { @@ -728,6 +1391,13 @@ function parseRequiredString(value: unknown, fieldName: string): string { return value } +function parseRequiredPositiveInteger(value: unknown, fieldName: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + throw new Error(`Deployment outbox payload is missing ${fieldName}`) + } + return value +} + function parseRequiredStringArray(value: unknown, fieldName: string): string[] { if ( !Array.isArray(value) || @@ -738,8 +1408,18 @@ function parseRequiredStringArray(value: unknown, fieldName: string): string[] { return value } -export const workflowDeploymentOutboxHandlers: OutboxHandlerRegistry = { - [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS]: syncActiveSideEffects, - [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS]: cleanupInactiveSideEffects, - [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS]: cleanupUndeployedSideEffects, +export function createWorkflowDeploymentOutboxHandlers( + options: { prepareWebhooks?: PrepareDeploymentWebhooksHook } = {} +): OutboxHandlerRegistry { + return { + [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2]: createPrepareDeploymentHandler( + options.prepareWebhooks ?? defaultPrepareDeploymentWebhooks + ), + [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS]: syncActiveSideEffects, + [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS]: cleanupInactiveSideEffects, + [WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS]: + cleanupUndeployedSideEffects, + } } + +export const workflowDeploymentOutboxHandlers = createWorkflowDeploymentOutboxHandlers() diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index d96b26ab05f..b5e2f77429a 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -174,7 +174,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) mergeSubblockStateWithValuesMock.mockImplementation((blocks) => blocks) - serializeWorkflowMock.mockReturnValue({ loops: {}, parallels: {} }) + serializeWorkflowMock.mockReturnValue({ blocks: [], loops: {}, parallels: {} }) buildTraceSpansMock.mockReturnValue({ traceSpans: [{ id: 'span-1' }], totalDuration: 123 }) findStartBlockMock.mockReturnValue({ blockId: 'start-block', diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index d04d9a1dced..29cad48f04d 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -21,6 +21,7 @@ import type { LoggingSession } from '@/lib/logs/execution/logging-session' import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { getUserEmailById } from '@/lib/users/queries' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { loadDeployedWorkflowState, @@ -38,8 +39,13 @@ import type { IterationContext, SerializableExecutionState, } from '@/executor/execution/types' -import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' +import type { + ExecutionResult, + NormalizedBlockOutput, + StartBlockRunMetadata, +} from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' +import { isRunMetadataEnabled } from '@/executor/utils/start-block' import { buildParallelSentinelEndId, buildSentinelEndId } from '@/executor/utils/subflow-utils' import { Serializer } from '@/serializer' @@ -743,6 +749,24 @@ async function executeWorkflowCoreImpl( } } + let startRunMetadata: StartBlockRunMetadata | undefined + if (resolvedTriggerBlockId) { + const entryBlock = serializedWorkflow.blocks.find( + (block) => block.id === resolvedTriggerBlockId + ) + if (entryBlock && isRunMetadataEnabled(entryBlock)) { + startRunMetadata = { + userEmail: await getUserEmailById(userId), + workspaceId: providedWorkspaceId, + workflowId, + executionId, + executionType: triggerType, + executionMode: metadata.executionMode ?? 'sync', + startTime: metadata.startTime, + } + } + } + const contextExtensions: ContextExtensions = { stream: !!onStream, selectedOutputs, @@ -770,6 +794,7 @@ async function executeWorkflowCoreImpl( dagIncomingEdges: snapshot.state?.dagIncomingEdges, snapshotState: snapshot.state, metadata, + startRunMetadata, abortSignal, includeFileBase64, base64MaxBytes, diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts new file mode 100644 index 00000000000..c2a6c967e70 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -0,0 +1,173 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockMaterializeExecutionData, mockSelect } = vi.hoisted(() => ({ + mockMaterializeExecutionData: vi.fn(), + mockSelect: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect }, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mockMaterializeExecutionData, + TRACE_STORE_REF_KEY: 'traceStoreRef', +})) + +import { + getExecutionInputForWorkflow, + getExecutionStateForWorkflow, + getLatestExecutionStateWithExecutionId, +} from '@/lib/workflows/executor/execution-state' + +const EXECUTION_STATE = { + blockStates: {}, + executedBlocks: ['block-1'], + blockLogs: [], + decisions: {}, + completedLoops: [], + activeExecutionPath: [], +} + +function createSelectChain(rows: unknown[]) { + const chain = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn().mockResolvedValue(rows), + } + chain.from.mockReturnValue(chain) + chain.where.mockReturnValue(chain) + chain.orderBy.mockReturnValue(chain) + return chain +} + +describe('execution state lookup', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMaterializeExecutionData.mockReset() + mockSelect.mockReset() + }) + + it('materializes externalized execution data for a specific execution', async () => { + const slimExecutionData = { + traceStoreRef: { + __simLargeValueRef: true, + id: 'value-1', + key: 'execution/workspace-1/workflow-1/execution-1/value.json', + kind: 'object', + size: 100, + version: 1, + executionId: 'execution-1', + }, + } + mockSelect.mockReturnValueOnce( + createSelectChain([ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) + ) + mockMaterializeExecutionData.mockResolvedValueOnce({ + executionState: EXECUTION_STATE, + }) + + const result = await getExecutionStateForWorkflow('execution-1', 'workflow-1') + + expect(mockMaterializeExecutionData).toHaveBeenCalledWith(slimExecutionData, { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + expect(result).toEqual(EXECUTION_STATE) + }) + + it('materializes externalized execution data when reusing workflow input', async () => { + const slimExecutionData = { + traceStoreRef: { + __simLargeValueRef: true, + id: 'value-1', + key: 'execution/workspace-1/workflow-1/execution-1/value.json', + kind: 'object', + size: 100, + version: 1, + executionId: 'execution-1', + }, + } + mockSelect.mockReturnValueOnce( + createSelectChain([ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: slimExecutionData, + }, + ]) + ) + mockMaterializeExecutionData.mockResolvedValueOnce({ + workflowInput: { leadId: 'lead-1' }, + }) + + const result = await getExecutionInputForWorkflow('execution-1', 'workflow-1') + + expect(result).toEqual({ + found: true, + input: { leadId: 'lead-1' }, + }) + expect(mockMaterializeExecutionData).toHaveBeenCalledWith(slimExecutionData, { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + }) + + it('checks older pointer-backed candidates when the latest has no execution state', async () => { + mockSelect.mockReturnValueOnce( + createSelectChain([ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-2' }, + }, + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionState: null, + traceStoreRef: { id: 'value-1' }, + }, + ]) + ) + mockMaterializeExecutionData + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ executionState: EXECUTION_STATE }) + + const result = await getLatestExecutionStateWithExecutionId('workflow-1') + + expect(result).toEqual({ + executionId: 'execution-1', + state: EXECUTION_STATE, + }) + expect(mockMaterializeExecutionData).toHaveBeenCalledTimes(2) + expect(mockMaterializeExecutionData).toHaveBeenNthCalledWith( + 1, + { + executionState: null, + traceStoreRef: { id: 'value-2' }, + }, + { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-2', + } + ) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index 862b823209a..3c8cb8f9fd9 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -1,9 +1,12 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq, or, sql } from 'drizzle-orm' +import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' -export interface ExecutionStateRecord { +const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10 + +interface ExecutionStateRecord { executionId: string state: SerializableExecutionState } @@ -27,16 +30,30 @@ function extractExecutionState(executionData: unknown): SerializableExecutionSta return isSerializableExecutionState(state) ? state : null } -export async function getExecutionState( +interface ExecutionStateRow { executionId: string -): Promise { - const [row] = await db - .select({ executionData: workflowExecutionLogs.executionData }) - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) + workflowId: string | null + workspaceId: string + executionData: unknown +} + +async function materializeExecutionDataFromRow( + row: ExecutionStateRow | undefined +): Promise | null> { + if (!row) return null - return extractExecutionState(row?.executionData) + return materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) +} + +async function extractExecutionStateFromRow( + row: ExecutionStateRow | undefined +): Promise { + const executionData = await materializeExecutionDataFromRow(row) + return extractExecutionState(executionData) } export async function getExecutionStateForWorkflow( @@ -44,7 +61,12 @@ export async function getExecutionStateForWorkflow( workflowId: string ): Promise { const [row] = await db - .select({ executionData: workflowExecutionLogs.executionData }) + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionData: workflowExecutionLogs.executionData, + }) .from(workflowExecutionLogs) .where( and( @@ -54,7 +76,7 @@ export async function getExecutionStateForWorkflow( ) .limit(1) - return extractExecutionState(row?.executionData) + return extractExecutionStateFromRow(row) } /** @@ -67,7 +89,12 @@ export async function getExecutionInputForWorkflow( workflowId: string ): Promise<{ found: boolean; input?: unknown }> { const [row] = await db - .select({ executionData: workflowExecutionLogs.executionData }) + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionData: workflowExecutionLogs.executionData, + }) .from(workflowExecutionLogs) .where( and( @@ -81,35 +108,46 @@ export async function getExecutionInputForWorkflow( return { found: false } } - const data = row.executionData as { workflowInput?: unknown } | null | undefined + const data = await materializeExecutionDataFromRow(row) return { found: true, input: data?.workflowInput } } -export async function getLatestExecutionState( - workflowId: string -): Promise { - const record = await getLatestExecutionStateWithExecutionId(workflowId) - return record?.state ?? null -} - export async function getLatestExecutionStateWithExecutionId( workflowId: string ): Promise { - const [row] = await db + const rows = await db .select({ executionId: workflowExecutionLogs.executionId, - executionData: workflowExecutionLogs.executionData, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionState: sql`${workflowExecutionLogs.executionData} -> 'executionState'`, + traceStoreRef: sql`${workflowExecutionLogs.executionData} -> ${TRACE_STORE_REF_KEY}`, }) .from(workflowExecutionLogs) .where( and( eq(workflowExecutionLogs.workflowId, workflowId), - sql`${workflowExecutionLogs.executionData} -> 'executionState' IS NOT NULL` + or( + sql`${workflowExecutionLogs.executionData} -> 'executionState' IS NOT NULL`, + sql`${workflowExecutionLogs.executionData} -> ${TRACE_STORE_REF_KEY} IS NOT NULL` + ) ) ) .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(1) + .limit(LATEST_EXECUTION_STATE_CANDIDATE_LIMIT) + + for (const row of rows) { + const state = await extractExecutionStateFromRow({ + executionId: row.executionId, + workflowId: row.workflowId, + workspaceId: row.workspaceId, + executionData: { + executionState: row.executionState, + [TRACE_STORE_REF_KEY]: row.traceStoreRef, + }, + }) + if (state) return { executionId: row.executionId, state } + } - const state = extractExecutionState(row?.executionData) - return row && state ? { executionId: row.executionId, state } : null + return null } diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts index db4af82822e..2a6d3f03182 100644 --- a/apps/sim/lib/workflows/lifecycle.test.ts +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -26,6 +26,9 @@ vi.mock('@sim/db', () => ({ select: mockSelect, transaction: mockTransaction, }, + workflow: { id: 'id' }, + workflowDeploymentOperation: { workflowId: 'workflowId', status: 'status' }, + workflowDeploymentVersion: { workflowId: 'workflowId', isActive: 'isActive' }, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -94,6 +97,9 @@ describe('workflow lifecycle', () => { const tx = { update: vi.fn().mockImplementation(() => createUpdateChain()), + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockResolvedValue([]), + })), } mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => callback(tx) @@ -102,7 +108,10 @@ describe('workflow lifecycle', () => { const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) expect(result.archived).toBe(true) - expect(tx.update).toHaveBeenCalledTimes(6) + expect(tx.update).toHaveBeenCalledTimes(7) + const supersedeSet = tx.update.mock.results[0]?.value.set + expect(supersedeSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'superseded' })) + expect(tx.delete).toHaveBeenCalledTimes(1) expect(mockWorkflowDeleted).toHaveBeenCalledWith({ workflowId: 'workflow-1', workspaceId: 'workspace-1', diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 82040e2750e..d1b08caed71 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -18,6 +18,8 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' +import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' +import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLifecycle') @@ -106,6 +108,9 @@ export async function archiveWorkflow( .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) await db.transaction(async (tx) => { + await supersedeInFlightDeploymentOperations(tx, workflowId) + await releaseWebhookPathClaims(tx, workflowId) + await tx .update(workflowSchedule) .set({ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index a97d73e4e56..db8a1bea308 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -48,6 +48,13 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { expandSurveyFormDefinitions: '_removed_expandSurveyFormDefinitions', filterCandidateId: '_removed_filterCandidateId', }, + clickup: { + workspaceId: 'workspaceSelector', + spaceId: 'spaceSelector', + listSpaceId: 'listSpaceSelector', + folderId: 'folderSelector', + listId: 'listSelector', + }, apollo: { contact_ids_bulk: 'contacts', account_ids_bulk: 'accounts', diff --git a/apps/sim/lib/workflows/operations/import-export.test.ts b/apps/sim/lib/workflows/operations/import-export.test.ts index 202a119f83a..d6c0ed0e2f9 100644 --- a/apps/sim/lib/workflows/operations/import-export.test.ts +++ b/apps/sim/lib/workflows/operations/import-export.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') +vi.mock('@/lib/api/client/request', () => ({ + requestJson: vi.fn().mockResolvedValue({}), +})) + import { extractWorkflowName, parseWorkflowJson, + persistImportedWorkflow, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' @@ -134,6 +139,67 @@ describe('workflow import/export parsing', () => { }) }) +describe('persistImportedWorkflow description handling', () => { + function buildContent(description?: string) { + const state = createLegacyState() + return JSON.stringify({ + data: { + version: '1.0', + workflow: { name: 'Imported Workflow' }, + state: { + ...state, + metadata: { name: 'Imported Workflow', description }, + }, + }, + }) + } + + async function importWithContent(content: string, descriptionOverride?: string) { + const createWorkflow = vi.fn().mockResolvedValue({ id: 'wf-1' }) + await persistImportedWorkflow({ + content, + filename: 'imported-workflow.json', + workspaceId: 'ws-1', + descriptionOverride, + createWorkflow, + }) + return createWorkflow.mock.calls[0][0].description as string + } + + it('scrubs placeholder metadata descriptions to an empty string', async () => { + expect(await importWithContent(buildContent('New workflow'))).toBe('') + expect( + await importWithContent(buildContent('Your first workflow - start building here!')) + ).toBe('') + }) + + it('scrubs name-equal metadata descriptions to an empty string', async () => { + expect(await importWithContent(buildContent('Imported Workflow'))).toBe('') + }) + + it('preserves meaningful metadata descriptions', async () => { + expect(await importWithContent(buildContent('Syncs leads from HubSpot to Slack'))).toBe( + 'Syncs leads from HubSpot to Slack' + ) + }) + + it('uses an empty string when no description is present', async () => { + expect(await importWithContent(buildContent(undefined))).toBe('') + }) + + it('prefers a meaningful override over metadata', async () => { + expect( + await importWithContent(buildContent('Metadata description'), 'Override description') + ).toBe('Override description') + }) + + it('falls back to meaningful metadata when the override is a placeholder', async () => { + expect(await importWithContent(buildContent('Metadata description'), 'New workflow')).toBe( + 'Metadata description' + ) + }) +}) + describe('sanitizePathSegment', () => { it('should preserve ASCII alphanumeric characters', () => { expect(sanitizePathSegment('workflow-123_abc')).toBe('workflow-123_abc') diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index fcf94979aff..06967ab7969 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -11,6 +11,7 @@ import { type WorkflowStateContractInput, workflowVariablesContract, } from '@/lib/api/contracts/workflows' +import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' import { type ExportWorkflowState, @@ -677,7 +678,10 @@ export async function persistImportedWorkflow({ const createdWorkflow = await createWorkflow({ name: workflowName, - description: descriptionOverride || workflowData.metadata?.description || 'Imported from JSON', + description: + getMeaningfulWorkflowDescription(descriptionOverride, workflowName) ?? + getMeaningfulWorkflowDescription(workflowData.metadata?.description, workflowName) ?? + '', workspaceId, folderId, sortOrder, diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 8288766b900..7b925510065 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -6,7 +6,11 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' -import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' +import { + getWorkflowDeploymentSummary, + performFullDeploy, +} from '@/lib/workflows/orchestration/deploy' +import { checkNeedsRedeployment } from '@/app/api/workflows/utils' const logger = createLogger('ChatDeployOrchestration') @@ -64,14 +68,43 @@ export async function performChatDeploy( ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), } - const deployResult = await performFullDeploy({ - workflowId, - userId, - versionDescription: params.versionDescription, - versionName: params.versionName, - }) - if (!deployResult.success) { - return { success: false, error: deployResult.error || 'Failed to deploy workflow' } + /** + * Only deploy when the draft drifted from the active version, and never + * while another attempt is in flight — a blocked retry must not admit a + * fresh deployment version on top of the pending one. + */ + const deploymentSummary = await getWorkflowDeploymentSummary(workflowId) + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + if (attemptStatus === 'preparing' || attemptStatus === 'activating') { + return { + success: false, + error: + 'A workflow deployment is still preparing. Retry chat deployment after it becomes active.', + } + } + + const needsRedeploy = + !deploymentSummary.activeDeployment || (await checkNeedsRedeployment(workflowId)) + + let deployResult: Awaited> | null = null + if (needsRedeploy) { + deployResult = await performFullDeploy({ + workflowId, + userId, + versionDescription: params.versionDescription, + versionName: params.versionName, + }) + if (!deployResult.success) { + return { success: false, error: deployResult.error || 'Failed to deploy workflow' } + } + if (deployResult.latestDeploymentAttempt?.status !== 'active') { + return { + success: false, + error: + deployResult.warnings?.[0] ?? + 'Workflow deployment is still preparing. Retry chat deployment after it becomes active.', + } + } } let encryptedPassword: string | null = null @@ -185,11 +218,17 @@ export async function performChatDeploy( success: true, chatId, chatUrl, - deployedAt: deployResult.deployedAt, - version: deployResult.version, + deployedAt: deployResult?.deployedAt ?? toDeployedAtDate(deploymentSummary), + version: deployResult?.version ?? deploymentSummary.activeDeployment?.version, } } +function toDeployedAtDate(summary: { + activeDeployment: { deployedAt: string } | null +}): Date | null { + return summary.activeDeployment ? new Date(summary.activeDeployment.deployedAt) : null +} + export interface PerformChatUndeployParams { chatId: string userId: string diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index ffe40b6fc9d..8df4a4fed44 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -10,11 +10,16 @@ const { mockRecordAudit, mockCaptureServerEvent, mockTransaction, - mockDeployWorkflow, - mockActivateWorkflowVersion, mockValidateWorkflowSchedules, mockValidateTriggerWebhookConfigForDeploy, mockEmitWorkflowDeployedEvent, + mockPrepareWorkflowDeployment, + mockPrepareWorkflowVersionActivation, + mockGetWorkflowDeploymentStatus, + mockEnqueueWorkflowDeploymentPreparation, + mockProcessWorkflowDeploymentOutboxEvent, + mockNotifySocketDeploymentChanged, + mockLoadWorkflowDeploymentSnapshot, mockTx, } = vi.hoisted(() => ({ mockLimit: vi.fn(), @@ -23,11 +28,16 @@ const { mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), mockTransaction: vi.fn(), - mockDeployWorkflow: vi.fn(), - mockActivateWorkflowVersion: vi.fn(), mockValidateWorkflowSchedules: vi.fn(), mockValidateTriggerWebhookConfigForDeploy: vi.fn(), mockEmitWorkflowDeployedEvent: vi.fn(), + mockPrepareWorkflowDeployment: vi.fn(), + mockPrepareWorkflowVersionActivation: vi.fn(), + mockGetWorkflowDeploymentStatus: vi.fn(), + mockEnqueueWorkflowDeploymentPreparation: vi.fn(), + mockProcessWorkflowDeploymentOutboxEvent: vi.fn(), + mockNotifySocketDeploymentChanged: vi.fn(), + mockLoadWorkflowDeploymentSnapshot: vi.fn(), mockTx: { select: vi.fn(() => ({ from: vi.fn(() => ({ @@ -59,7 +69,11 @@ vi.mock('@sim/db', () => ({ })), transaction: mockTransaction, }, - workflow: { id: 'workflow.id' }, + workflow: { + id: 'workflow.id', + deployedAt: 'workflow.deployedAt', + workspaceId: 'workflow.workspaceId', + }, workflowDeploymentVersion: { workflowId: 'workflowDeploymentVersion.workflowId', version: 'workflowDeploymentVersion.version', @@ -80,13 +94,22 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/workflows/deployment-outbox', () => ({ - enqueueWorkflowDeploymentSideEffects: vi.fn().mockResolvedValue('outbox-1'), + enqueueWorkflowDeploymentPreparation: mockEnqueueWorkflowDeploymentPreparation, enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'), - processWorkflowDeploymentOutboxEvent: vi.fn().mockResolvedValue('completed'), + notifySocketDeploymentChanged: mockNotifySocketDeploymentChanged, + processWorkflowDeploymentOutboxEvent: mockProcessWorkflowDeploymentOutboxEvent, + DEPLOYMENT_READINESS_COMPONENTS: ['webhooks', 'schedules', 'mcp'], +})) + +vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({ + getWorkflowDeploymentStatus: mockGetWorkflowDeploymentStatus, + prepareWorkflowDeployment: mockPrepareWorkflowDeployment, + prepareWorkflowVersionActivation: mockPrepareWorkflowVersionActivation, })) vi.mock('@/lib/workspace-events/emitter', () => ({ emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent, + emitWorkflowUndeployedEvent: vi.fn(), })) vi.mock('@/lib/core/config/env', () => ({ @@ -103,29 +126,16 @@ vi.mock('@/lib/posthog/server', () => ({ })) vi.mock('@/lib/workflows/persistence/utils', () => ({ - activateWorkflowVersion: mockActivateWorkflowVersion, - activateWorkflowVersionById: vi.fn(), - deployWorkflow: mockDeployWorkflow, - loadWorkflowDeploymentSnapshot: vi.fn(), + loadWorkflowDeploymentSnapshot: mockLoadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, undeployWorkflow: vi.fn(), })) -vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ - removeMcpToolsForWorkflow: vi.fn(), - syncMcpToolsForWorkflow: vi.fn(), -})) - vi.mock('@/lib/webhooks/deploy', () => ({ - cleanupWebhooksForWorkflow: vi.fn(), - restorePreviousVersionWebhooks: vi.fn(), - saveTriggerWebhooksForDeploy: vi.fn(), validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy, })) vi.mock('@/lib/workflows/schedules', () => ({ - cleanupDeploymentVersion: vi.fn(), - createSchedulesForDeploy: vi.fn(), validateWorkflowSchedules: mockValidateWorkflowSchedules, })) @@ -244,37 +254,226 @@ describe('performFullDeploy workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-default', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-1', + version: 4, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 1, + status: 'active', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'ready', updatedAt: now.toISOString() }, + mcp: { status: 'ready', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-default', + requestHash: 'hash', + actorId: 'user-1', + completedAt: now, + createdAt: now, + updatedAt: now, + } + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed') + mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) mockLimit.mockResolvedValue([ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, ]) - mockDeployWorkflow.mockResolvedValue({ - success: true, - deployedAt: new Date(), - version: 4, - deploymentVersionId: 'dv-1', - previousVersionId: null, - currentState: { blocks: {} }, + mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: now.getTime(), + }) + mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) + mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-default') + mockPrepareWorkflowDeployment.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-1', + version: 4, + deployedAt: now, + }, + latestOperation: operation, }) }) - it('emits workflow_deployed after a successful deploy', async () => { + it('always admits deploys through v2 without legacy immediate activation', async () => { const result = await performFullDeploy({ workflowId: 'workflow-1', userId: 'user-1', }) expect(result.success).toBe(true) - expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1) - expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({ + expect(mockPrepareWorkflowDeployment).toHaveBeenCalledTimes(1) + expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith( + mockTx, + expect.objectContaining({ protocolVersion: 2 }) + ) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) + + it('keeps a first deploy pending without claiming an active deployment', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-1', workflowId: 'workflow-1', - workflowName: 'My Workflow', - workspaceId: 'workspace-1', - version: 4, + deploymentVersionId: 'dv-candidate', + version: 1, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 1, + status: 'preparing', + componentReadiness: { + webhooks: { status: 'pending', updatedAt: now.toISOString() }, + schedules: { status: 'pending', updatedAt: now.toISOString() }, + mcp: { status: 'pending', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-1', + requestHash: 'hash', + actorId: 'user-1', + completedAt: null, + createdAt: now, + updatedAt: now, + } + mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: now.getTime(), }) + mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) + mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-1') + mockPrepareWorkflowDeployment.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending') + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: operation, + }) + + const result = await performFullDeploy({ + workflowId: 'workflow-1', + userId: 'user-1', + requestId: 'request-1', + }) + + expect(result).toMatchObject({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { + id: 'operation-1', + status: 'preparing', + deploymentVersionId: 'dv-candidate', + }, + warnings: [expect.stringContaining('workflow remains undeployed')], + }) + expect(result.deployedAt).toBeUndefined() }) - it('does not emit when the deploy fails', async () => { - mockDeployWorkflow.mockResolvedValueOnce({ success: false, error: 'nope' }) + it('preserves the old active deployment while a redeploy prepares', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-2', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-candidate', + version: 5, + previousActiveVersionId: 'dv-live', + action: 'deploy', + protocolVersion: 2, + generation: 2, + status: 'preparing', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'pending', updatedAt: now.toISOString() }, + mcp: { status: 'pending', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-2', + requestHash: 'hash', + actorId: 'user-1', + completedAt: null, + createdAt: now, + updatedAt: now, + } + mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: now.getTime(), + }) + mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) + mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-2') + mockPrepareWorkflowDeployment.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending') + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-live', + version: 4, + deployedAt: now, + }, + latestOperation: operation, + }) + + const result = await performFullDeploy({ + workflowId: 'workflow-1', + userId: 'user-1', + requestId: 'request-2', + }) + + expect(result).toMatchObject({ + success: true, + /** + * Top-level version identifies the snapshot this call admitted, while + * activeDeployment keeps reporting what is actually live during the + * pending cutover. + */ + deploymentVersionId: 'dv-candidate', + version: 5, + activeDeployment: { + deploymentVersionId: 'dv-live', + version: 4, + }, + latestDeploymentAttempt: { + deploymentVersionId: 'dv-candidate', + status: 'preparing', + }, + }) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) + + it('surfaces v2 admission failure without falling back to legacy activation', async () => { + mockPrepareWorkflowDeployment.mockResolvedValueOnce({ + success: false, + reason: 'invalid_request', + error: 'nope', + }) const result = await performFullDeploy({ workflowId: 'workflow-1', @@ -282,18 +481,63 @@ describe('performFullDeploy workspace event emission', () => { }) expect(result.success).toBe(false) + expect(result.error).toBe('nope') expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) - it('emission rejection does not fail the deploy', async () => { - mockEmitWorkflowDeployedEvent.mockRejectedValueOnce(new Error('emit failed')) + it('returns a failure response when this request attempt fails terminally inline', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-conflict', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-candidate', + version: 5, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 2, + status: 'preparing', + componentReadiness: { + webhooks: { status: 'pending', updatedAt: now.toISOString() }, + schedules: { status: 'pending', updatedAt: now.toISOString() }, + mcp: { status: 'pending', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-conflict', + requestHash: 'hash', + actorId: 'user-1', + completedAt: null, + createdAt: now, + updatedAt: now, + } + mockPrepareWorkflowDeployment.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed') + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: { + ...operation, + status: 'failed', + errorCode: 'webhook_path_conflict', + errorMessage: 'Webhook path "/leads" is already in use. Choose a different path.', + completedAt: now, + }, + }) const result = await performFullDeploy({ workflowId: 'workflow-1', userId: 'user-1', + requestId: 'request-conflict', }) - expect(result.success).toBe(true) + expect(result).toMatchObject({ + success: false, + error: 'Webhook path "/leads" is already in use. Choose a different path.', + errorCode: 'conflict', + }) }) }) @@ -301,31 +545,129 @@ describe('performActivateVersion workspace event emission', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-activate-default', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-2', + version: 2, + previousActiveVersionId: 'dv-1', + action: 'activate', + protocolVersion: 2, + generation: 4, + status: 'active', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'ready', updatedAt: now.toISOString() }, + mcp: { status: 'ready', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-activate-default', + requestHash: 'hash', + actorId: 'user-1', + completedAt: now, + createdAt: now, + updatedAt: now, + } + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed') + mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }]) - mockActivateWorkflowVersion.mockResolvedValue({ - success: true, - deployedAt: new Date(), - previousVersionId: 'dv-1', + mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate-default') + mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-2', + version: 2, + deployedAt: now, + }, + latestOperation: operation, }) }) - it('emits workflow_deployed when activating a version (rollback/activation)', async () => { + it('always admits version activation through v2 without legacy activation', async () => { const result = await performActivateVersion({ workflowId: 'workflow-1', version: 2, userId: 'user-1', - workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, }) expect(result.success).toBe(true) - expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({ + expect(mockPrepareWorkflowVersionActivation).toHaveBeenCalledTimes(1) + expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith( + mockTx, + expect.objectContaining({ protocolVersion: 2 }) + ) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) + + it('keeps the current version active while version activation prepares', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + const operation = { + id: 'operation-activate', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-2', + version: 2, + previousActiveVersionId: 'dv-1', + action: 'activate', + protocolVersion: 2, + generation: 4, + status: 'preparing', + componentReadiness: { + webhooks: { status: 'pending', updatedAt: now.toISOString() }, + schedules: { status: 'pending', updatedAt: now.toISOString() }, + mcp: { status: 'pending', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-activate', + requestHash: 'hash', + actorId: 'user-1', + completedAt: null, + createdAt: now, + updatedAt: now, + } + mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate') + mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => { + await input.onPrepareTransaction?.(mockTx, operation) + return { success: true, operation, reused: false } + }) + mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending') + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: { + deploymentVersionId: 'dv-1', + version: 1, + deployedAt: now, + }, + latestOperation: operation, + }) + + const result = await performActivateVersion({ workflowId: 'workflow-1', - workflowName: 'My Workflow', - workspaceId: 'workspace-1', version: 2, + userId: 'user-1', + requestId: 'request-activate', }) + + expect(result).toMatchObject({ + success: true, + activeDeployment: { + deploymentVersionId: 'dv-1', + version: 1, + }, + latestDeploymentAttempt: { + id: 'operation-activate', + deploymentVersionId: 'dv-2', + status: 'preparing', + }, + warnings: [expect.stringContaining('prior workflow version remains active')], + }) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) it('does not emit when the version is already active (no-op activation)', async () => { @@ -337,24 +679,27 @@ describe('performActivateVersion workspace event emission', () => { workflowId: 'workflow-1', version: 2, userId: 'user-1', - workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, }) expect(result.success).toBe(true) expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) - it('does not emit when activation fails', async () => { - mockActivateWorkflowVersion.mockResolvedValueOnce({ success: false, error: 'nope' }) + it('surfaces v2 activation admission failure without legacy fallback', async () => { + mockPrepareWorkflowVersionActivation.mockResolvedValueOnce({ + success: false, + reason: 'invalid_request', + error: 'nope', + }) const result = await performActivateVersion({ workflowId: 'workflow-1', version: 2, userId: 'user-1', - workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, }) expect(result.success).toBe(false) + expect(result.error).toBe('nope') expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 7f7617f3402..8a9f49f4a14 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -2,6 +2,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -10,54 +12,70 @@ import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy' import { - enqueueWorkflowDeploymentSideEffects, + DEPLOYMENT_ERROR_CODES, + type DeploymentComponentStatus, + isDeploymentOperationAction, + isDeploymentOperationStatus, + isNonRetryableDeploymentErrorCode, + parseDeploymentReadiness, +} from '@/lib/workflows/deployment-lifecycle' +import { + DEPLOYMENT_READINESS_COMPONENTS, + enqueueWorkflowDeploymentPreparation, enqueueWorkflowUndeploySideEffects, + notifySocketDeploymentChanged, processWorkflowDeploymentOutboxEvent, } from '@/lib/workflows/deployment-outbox' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { - activateWorkflowVersion, - deployWorkflow, + getWorkflowDeploymentStatus, + prepareWorkflowDeployment, + prepareWorkflowVersionActivation, + type WorkflowDeploymentOperation, + type WorkflowDeploymentStatus, +} from '@/lib/workflows/persistence/deployment-operations' +import { + loadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables, undeployWorkflow, } from '@/lib/workflows/persistence/utils' import { validateWorkflowSchedules } from '@/lib/workflows/schedules' -import { - emitWorkflowDeployedEvent, - emitWorkflowUndeployedEvent, -} from '@/lib/workspace-events/emitter' +import { emitWorkflowUndeployedEvent } from '@/lib/workspace-events/emitter' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeployOrchestration') -/** - * Notifies the socket server that a workflow's deployment state has changed, - * so all connected clients can refresh their deployment queries. - */ -async function notifySocketDeploymentChanged(workflowId: string): Promise { - try { - const response = await fetch(`${getSocketServerUrl()}/api/workflow-deployed`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }) - if (!response.ok) { - logger.warn( - `Socket deployment notification failed (${response.status}) for workflow ${workflowId}` - ) - } - } catch (error) { - logger.error('Error sending workflow deployed event to socket server', error) +type DeploymentReadinessSummaryStatus = DeploymentComponentStatus | 'not_applicable' + +export interface ActiveDeploymentResult { + deploymentVersionId: string + version: number + deployedAt: string +} + +export interface DeploymentAttemptResult { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: DeploymentReadinessSummaryStatus + schedules: DeploymentReadinessSummaryStatus + mcp: DeploymentReadinessSummaryStatus } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null } export interface PerformFullDeployParams { workflowId: string userId: string - workflowName?: string /** * Optional summary of what changed, stored on the created deployment version. * The copilot deploy tools require this; the UI deploy route sets it @@ -71,11 +89,6 @@ export interface PerformFullDeployParams { */ versionName?: string requestId?: string - /** - * Optional NextRequest for external webhook subscriptions. - * If not provided, a synthetic request is constructed from the base URL. - */ - request?: NextRequest /** * Override the actor ID used in audit logs and the `deployedBy` field. * Defaults to `userId`. Use `'admin-api'` for admin-initiated actions. @@ -88,21 +101,21 @@ export interface PerformFullDeployResult { deployedAt?: Date version?: number deploymentVersionId?: string + activeDeployment?: ActiveDeploymentResult | null + latestDeploymentAttempt?: DeploymentAttemptResult | null error?: string errorCode?: OrchestrationErrorCode warnings?: string[] } /** - * Performs a full workflow deployment: creates a deployment version, queues - * external side effects transactionally, processes that outbox event after - * commit, and notifies clients. Both the deploy API route and the copilot - * deploy tools must use this single function so behaviour stays consistent. + * Admits a deployment through the v2 prepare/activate protocol. The candidate + * version remains inactive until every required side effect is ready. */ export async function performFullDeploy( params: PerformFullDeployParams ): Promise { - const { workflowId, userId, workflowName } = params + const { workflowId, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() @@ -116,101 +129,308 @@ export async function performFullDeploy( return { success: false, error: 'Workflow not found', errorCode: 'not_found' } } - const workflowData = workflowRecord as Record - let outboxEventId: string | undefined + try { + return await performStableFullDeploy({ + params, + actorId, + requestId, + }) + } catch (error) { + logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error }) + return { + success: false, + error: getErrorMessage(error, 'Failed to prepare workflow deployment'), + errorCode: 'internal', + } + } +} - const deployResult = await deployWorkflow({ - workflowId, - deployedBy: actorId, - workflowName: workflowName || workflowRecord.name || undefined, - description: params.versionDescription, - name: params.versionName, - validateWorkflowState: async (workflowState) => { - const scheduleValidation = validateWorkflowSchedules(workflowState.blocks) - if (!scheduleValidation.isValid) { - return { - success: false, - error: `Invalid schedule configuration: ${scheduleValidation.error}`, - errorCode: 'validation', - } - } - const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks) - if (!triggerValidation.success) { - return { - success: false, - error: triggerValidation.error?.message || 'Invalid trigger configuration', - errorCode: 'validation', - } +async function performStableFullDeploy(params: { + params: PerformFullDeployParams + actorId: string + requestId: string +}): Promise { + const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) + if (!workflowState) { + return { + success: false, + error: 'Failed to load workflow state', + errorCode: 'validation', + } + } + + const validation = await validateDeploymentState(workflowState.blocks) + if (!validation.success) return validation + + let outboxEventId: string | undefined + const prepared = await prepareWorkflowDeployment({ + workflowId: params.params.workflowId, + actorId: params.actorId, + requestHash: createDeploymentRequestHash({ + action: 'deploy', + workflowId: params.params.workflowId, + userId: params.params.userId, + workflowState, + versionName: params.params.versionName ?? null, + versionDescription: params.params.versionDescription ?? null, + }), + idempotencyKey: params.requestId, + workflowState, + name: params.params.versionName, + description: params.params.versionDescription, + readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, + onPrepareTransaction: async (tx, operation) => { + if (!operation.deploymentVersionId || operation.version === null) { + throw new Error('Prepared deployment operation is missing its target version') } - return { success: true } - }, - onDeployTransaction: async (tx, result) => { - outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, { - workflowId, - deploymentVersionId: result.deploymentVersionId, - userId, - requestId, + outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, { + protocolVersion: operation.protocolVersion, + operationId: operation.id, + generation: operation.generation, + workflowId: operation.workflowId, + deploymentVersionId: operation.deploymentVersionId, + version: operation.version, + userId: params.params.userId, + requestId: params.requestId, + checkpoints: {}, }) }, }) - if (!deployResult.success) { - const error = deployResult.error || 'Failed to deploy workflow' + if (!prepared.success) { return { success: false, - error, - errorCode: deployResult.errorCode, + error: prepared.error, + errorCode: mapPrepareFailureCode(prepared.reason), } } - const deployedAt = deployResult.deployedAt! - const deploymentVersionId = deployResult.deploymentVersionId - const previousVersionId = deployResult.previousVersionId - const deploymentSnapshot = deployResult.currentState + const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId) + const deploymentStatus = await getWorkflowDeploymentStatus(params.params.workflowId) + const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, deploymentStatus) + if (inlineFailure) return inlineFailure + const result = buildStableDeploymentResult(deploymentStatus, processResult) + /** + * The top-level version identifies the snapshot THIS call admitted, even + * while cutover is still pending — otherwise callers would attribute the + * deploy to the previous live version. `activeDeployment` keeps reporting + * what is actually live. + */ + return { + ...result, + version: prepared.operation.version, + deploymentVersionId: prepared.operation.deploymentVersionId, + } +} - if (!deploymentVersionId || !deploymentSnapshot) { - await undeployWorkflow({ workflowId }) - return { success: false, error: 'Failed to resolve deployment version' } +/** + * Surfaces a synchronous failure when the attempt created by this request + * already failed terminally, so callers get an error response instead of a + * success payload with a buried failed status. + */ +function buildInlinePreparationFailure( + operationId: string, + status: WorkflowDeploymentStatus +): { success: false; error: string; errorCode: OrchestrationErrorCode } | null { + const latest = status.latestOperation + if (!latest || latest.id !== operationId || latest.status !== 'failed') return null + return { + success: false, + error: latest.errorMessage || 'Deployment preparation failed', + errorCode: + latest.errorCode === DEPLOYMENT_ERROR_CODES.webhookPathConflict + ? 'conflict' + : latest.errorCode === DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration + ? 'validation' + : 'internal', } +} - recordAudit({ - workspaceId: (workflowData.workspaceId as string) || null, - actorId: actorId, - action: AuditAction.WORKFLOW_DEPLOYED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: (workflowData.name as string) || undefined, - description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`, - metadata: { - deploymentVersionId, - version: deployResult.version, - previousVersionId: previousVersionId || undefined, - }, - request: params.request, - }) +async function validateDeploymentState( + blocks: Record +): Promise< + | { success: true } + | { success: false; error: string; errorCode: Extract } +> { + const scheduleValidation = validateWorkflowSchedules(blocks) + if (!scheduleValidation.isValid) { + return { + success: false, + error: `Invalid schedule configuration: ${scheduleValidation.error}`, + errorCode: 'validation', + } + } + const triggerValidation = await validateTriggerWebhookConfigForDeploy(blocks) + if (!triggerValidation.success) { + return { + success: false, + error: triggerValidation.error?.message || 'Invalid trigger configuration', + errorCode: 'validation', + } + } + return { success: true } +} - const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) - await notifySocketDeploymentChanged(workflowId) +function createDeploymentRequestHash(value: Record): string { + return sha256Hex(JSON.stringify(value)) +} - const workspaceId = workflowData.workspaceId as string | null - if (workspaceId) { - void emitWorkflowDeployedEvent({ - workflowId, - workflowName: (workflowData.name as string) || workflowId, - workspaceId, - version: deployResult.version ?? null, +function mapPrepareFailureCode( + reason: + | 'workflow_not_found' + | 'workflow_archived' + | 'deployment_version_not_found' + | 'idempotency_conflict' + | 'invalid_request' +): OrchestrationErrorCode { + if (reason === 'workflow_not_found' || reason === 'deployment_version_not_found') { + return 'not_found' + } + if (reason === 'idempotency_conflict') return 'conflict' + return 'validation' +} + +async function processStableDeploymentPreparationNow( + outboxEventId: string | undefined, + requestId: string +): Promise { + if (!outboxEventId) return undefined + try { + return await processWorkflowDeploymentOutboxEvent(outboxEventId) + } catch (error) { + logger.warn(`[${requestId}] Inline deployment preparation errored; outbox will retry`, { + outboxEventId, + error, }) + return 'processing_error' } +} + +function buildStableDeploymentResult( + status: WorkflowDeploymentStatus, + processResult: string | undefined +): PerformFullDeployResult { + const activeDeployment = status.activeDeployment + ? { + deploymentVersionId: status.activeDeployment.deploymentVersionId, + version: status.activeDeployment.version, + deployedAt: status.activeDeployment.deployedAt.toISOString(), + } + : null + const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation) + const warning = getStableDeploymentWarning( + latestDeploymentAttempt, + processResult, + activeDeployment !== null + ) return { success: true, - deployedAt, - version: deployResult.version, - deploymentVersionId, - warnings: sideEffectWarning ? [sideEffectWarning] : undefined, + deployedAt: status.activeDeployment?.deployedAt, + version: status.activeDeployment?.version, + deploymentVersionId: status.activeDeployment?.deploymentVersionId, + activeDeployment, + latestDeploymentAttempt, + warnings: warning ? [warning] : undefined, } } +/** + * Returns the active deployment and latest attempt without mutating deployment state. + */ +export async function getWorkflowDeploymentSummary(workflowId: string): Promise<{ + activeDeployment: ActiveDeploymentResult | null + latestDeploymentAttempt: DeploymentAttemptResult | null + warnings?: string[] +}> { + const result = buildStableDeploymentResult( + await getWorkflowDeploymentStatus(workflowId), + undefined + ) + return { + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + warnings: result.warnings, + } +} + +function summarizeDeploymentOperation( + operation: WorkflowDeploymentOperation | null +): DeploymentAttemptResult | null { + if (!operation) return null + if ( + !isDeploymentOperationAction(operation.action) || + !isDeploymentOperationStatus(operation.status) + ) { + return null + } + const readiness = parseDeploymentReadiness(operation.componentReadiness) + const componentStatus = ( + component: (typeof DEPLOYMENT_READINESS_COMPONENTS)[number] + ): DeploymentReadinessSummaryStatus => readiness?.[component]?.status ?? 'not_applicable' + + return { + id: operation.id, + deploymentVersionId: operation.deploymentVersionId, + version: operation.version, + action: operation.action, + status: operation.status, + readiness: { + webhooks: componentStatus('webhooks'), + schedules: componentStatus('schedules'), + mcp: componentStatus('mcp'), + }, + requestedAt: operation.createdAt.toISOString(), + activatedAt: + operation.status === 'active' ? (operation.completedAt?.toISOString() ?? null) : null, + error: + operation.errorCode && operation.errorMessage + ? { + code: operation.errorCode, + message: operation.errorMessage, + retryable: !isNonRetryableDeploymentErrorCode(operation.errorCode), + } + : null, + } +} + +function getStableDeploymentWarning( + attempt: DeploymentAttemptResult | null, + processResult: string | undefined, + hasActiveDeployment: boolean +): string | undefined { + if (!attempt) return undefined + if (attempt.status === 'preparing' || attempt.status === 'activating') { + if (processResult === 'processing_error') { + return hasActiveDeployment + ? 'Deployment preparation hit an error and will retry automatically. The prior workflow version remains active until cutover.' + : 'Deployment preparation hit an error and will retry automatically. The workflow remains undeployed until activation.' + } + return hasActiveDeployment + ? 'Deployment preparation is queued and may finish shortly. The prior workflow version remains active until cutover.' + : 'Deployment preparation is queued and may finish shortly. The workflow remains undeployed until activation.' + } + if (attempt.status === 'failed') { + return hasActiveDeployment + ? 'Deployment preparation failed. The prior workflow version remains active.' + : 'Deployment preparation failed. The workflow remains undeployed.' + } + if (attempt.status === 'superseded') { + return 'This deployment attempt was superseded by a newer request.' + } + if (processResult === 'dead_letter' || processResult === 'not_found') { + return 'Deployment activation completed, but its post-activation event could not be retried automatically.' + } + if ( + processResult === 'pending' || + processResult === 'processing' || + processResult === 'lease_lost' + ) { + return 'Deployment activation completed, and post-activation notifications are queued.' + } + return undefined +} + export interface PerformFullUndeployParams { workflowId: string userId: string @@ -304,9 +524,7 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string - workflow: Record requestId?: string - request?: NextRequest /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string } @@ -314,6 +532,8 @@ export interface PerformActivateVersionParams { export interface PerformActivateVersionResult { success: boolean deployedAt?: Date + activeDeployment?: ActiveDeploymentResult | null + latestDeploymentAttempt?: DeploymentAttemptResult | null error?: string errorCode?: OrchestrationErrorCode warnings?: string[] @@ -339,15 +559,12 @@ export interface PerformRevertToVersionResult { } /** - * Activates an existing deployment version: validates schedules, activates the - * version, queues external side effects transactionally, processes that outbox - * event after commit, and records an audit entry. Both the deployment version - * PATCH handler and the admin activate route must use this function. + * Admits an existing version through the v2 prepare/activate protocol. */ export async function performActivateVersion( params: PerformActivateVersionParams ): Promise { - const { workflowId, version, userId, workflow } = params + const { workflowId, version, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() @@ -377,7 +594,15 @@ export async function performActivateVersion( .where(eq(workflowTable.id, workflowId)) .limit(1) - return { success: true, deployedAt: workflowDeployment?.deployedAt ?? new Date(), warnings: [] } + const status = await getWorkflowDeploymentStatus(workflowId) + const stableResult = buildStableDeploymentResult(status, 'completed') + return { + success: true, + deployedAt: stableResult.deployedAt ?? workflowDeployment?.deployedAt ?? new Date(), + activeDeployment: stableResult.activeDeployment, + latestDeploymentAttempt: stableResult.latestDeploymentAttempt, + warnings: stableResult.warnings, + } } const deployedState = versionRow.state as { blocks?: Record } @@ -406,56 +631,88 @@ export async function performActivateVersion( } } - let outboxEventId: string | undefined - const result = await activateWorkflowVersion({ - workflowId, - version, - onActivateTransaction: async (tx, activation) => { - outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, { - workflowId, - deploymentVersionId: activation.deploymentVersionId, - userId, - requestId, - forceRecreateSubscriptions: true, - }) - }, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to activate version' } + try { + return await performStableVersionActivation({ + workflowId, + deploymentVersionId: versionRow.id, + version, + userId, + actorId, + requestId, + }) + } catch (error) { + logger.error(`[${requestId}] Version activation preparation failed`, { + workflowId, + version, + error, + }) + return { + success: false, + error: getErrorMessage(error, 'Failed to prepare version activation'), + errorCode: 'internal', + } } +} - recordAudit({ - workspaceId: (workflow.workspaceId as string) || null, - actorId: actorId, - action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - description: `Activated deployment version ${version}`, - resourceName: (workflow.name as string) || undefined, - metadata: { - version, - deploymentVersionId: versionRow.id, - previousVersionId: result.previousVersionId || undefined, +async function performStableVersionActivation(params: { + workflowId: string + deploymentVersionId: string + version: number + userId: string + actorId: string + requestId: string +}): Promise { + let outboxEventId: string | undefined + const prepared = await prepareWorkflowVersionActivation({ + workflowId: params.workflowId, + deploymentVersionId: params.deploymentVersionId, + actorId: params.actorId, + requestHash: createDeploymentRequestHash({ + action: 'activate', + workflowId: params.workflowId, + deploymentVersionId: params.deploymentVersionId, + version: params.version, + userId: params.userId, + }), + idempotencyKey: params.requestId, + readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, + onPrepareTransaction: async (tx, operation) => { + if (!operation.deploymentVersionId || operation.version === null) { + throw new Error('Prepared activation operation is missing its target version') + } + outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, { + protocolVersion: operation.protocolVersion, + operationId: operation.id, + generation: operation.generation, + workflowId: operation.workflowId, + deploymentVersionId: operation.deploymentVersionId, + version: operation.version, + userId: params.userId, + requestId: params.requestId, + checkpoints: {}, + }) }, }) - const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) - await notifySocketDeploymentChanged(workflowId) - - const activationWorkspaceId = (workflow.workspaceId as string) || null - if (activationWorkspaceId) { - void emitWorkflowDeployedEvent({ - workflowId, - workflowName: (workflow.name as string) || workflowId, - workspaceId: activationWorkspaceId, - version, - }) + if (!prepared.success) { + return { + success: false, + error: prepared.error, + errorCode: mapPrepareFailureCode(prepared.reason), + } } + const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId) + const status = await getWorkflowDeploymentStatus(params.workflowId) + const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, status) + if (inlineFailure) return inlineFailure + const result = buildStableDeploymentResult(status, processResult) return { - success: true, + success: result.success, deployedAt: result.deployedAt, - warnings: sideEffectWarning ? [sideEffectWarning] : undefined, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, + warnings: result.warnings, } } diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index d1a25867e59..b47ab417e3f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -3,6 +3,7 @@ export { performChatUndeploy, } from './chat-deploy' export { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, diff --git a/apps/sim/lib/workflows/orchestration/types.test.ts b/apps/sim/lib/workflows/orchestration/types.test.ts new file mode 100644 index 00000000000..26be2502be4 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/types.test.ts @@ -0,0 +1,17 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' + +describe('statusForOrchestrationError', () => { + it.each([ + ['validation', 400], + ['not_found', 404], + ['conflict', 409], + ['internal', 500], + [undefined, 500], + ] as const)('maps %s to %i', (code, expected) => { + expect(statusForOrchestrationError(code)).toBe(expected) + }) +}) diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts index 03dd0050dca..70c715ffb2c 100644 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ b/apps/sim/lib/workflows/orchestration/types.ts @@ -7,5 +7,6 @@ export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | ' export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { if (code === 'validation') return 400 if (code === 'not_found') return 404 + if (code === 'conflict') return 409 return 500 } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 15917517fd4..4b07cd492fe 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -54,11 +54,14 @@ export interface PerformUpdateWorkflowParams { currentFolderId?: string | null /** Prior `locked` value, used to detect lock-state transitions for instrumentation. */ currentLocked?: boolean | null + /** Prior `forkSyncExcluded` value, used to detect exclusion transitions for instrumentation. */ + currentForkSyncExcluded?: boolean | null name?: string description?: string | null folderId?: string | null sortOrder?: number locked?: boolean + forkSyncExcluded?: boolean requestId?: string } @@ -74,6 +77,7 @@ export interface PerformUpdateWorkflowResult { folderId: string | null sortOrder: number | null locked: boolean | null + forkSyncExcluded: boolean | null createdAt: Date updatedAt: Date archivedAt: Date | null @@ -315,6 +319,7 @@ export async function performUpdateWorkflow( if (params.folderId !== undefined) updateData.folderId = params.folderId if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder if (params.locked !== undefined) updateData.locked = params.locked + if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded const [updatedWorkflow] = await db .update(workflow) @@ -328,6 +333,7 @@ export async function performUpdateWorkflow( folderId: workflow.folderId, sortOrder: workflow.sortOrder, locked: workflow.locked, + forkSyncExcluded: workflow.forkSyncExcluded, createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, archivedAt: workflow.archivedAt, @@ -366,6 +372,36 @@ export async function performUpdateWorkflow( ) } + if ( + params.forkSyncExcluded !== undefined && + params.forkSyncExcluded !== (params.currentForkSyncExcluded ?? false) + ) { + const workspaceId = updatedWorkflow.workspaceId + recordAudit({ + workspaceId: workspaceId ?? null, + actorId: params.userId, + action: params.forkSyncExcluded + ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED + : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: params.workflowId, + resourceName: updatedWorkflow.name, + description: `${params.forkSyncExcluded ? 'Excluded' : 'Included'} workflow "${updatedWorkflow.name}" ${params.forkSyncExcluded ? 'from' : 'in'} fork sync`, + metadata: { forkSyncExcluded: params.forkSyncExcluded }, + }) + + captureServerEvent( + params.userId, + 'workflow_fork_sync_exclusion_toggled', + { + workflow_id: params.workflowId, + ...(workspaceId ? { workspace_id: workspaceId } : {}), + fork_sync_excluded: params.forkSyncExcluded, + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + return { success: true, workflow: updatedWorkflow } } catch (error) { logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error }) diff --git a/apps/sim/lib/workflows/persistence/deployment-operations.test.ts b/apps/sim/lib/workflows/persistence/deployment-operations.test.ts new file mode 100644 index 00000000000..5692b0e8a4b --- /dev/null +++ b/apps/sim/lib/workflows/persistence/deployment-operations.test.ts @@ -0,0 +1,491 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + ...dbChainMock, + workflow: schemaMock.workflow, + workflowDeploymentOperation: schemaMock.workflowDeploymentOperation, + workflowDeploymentVersion: schemaMock.workflowDeploymentVersion, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: mockGenerateId, +})) + +import { + activateDeploymentOperation, + markDeploymentComponentReadiness, + markDeploymentOperationFailed, + prepareWorkflowDeployment, +} from '@/lib/workflows/persistence/deployment-operations' + +const WORKFLOW_ID = 'workflow-1' +const NOW = new Date('2026-07-14T08:00:00.000Z') + +function operationRow( + overrides: Partial<{ + id: string + workflowId: string + deploymentVersionId: string + version: number + previousActiveVersionId: string | null + action: 'deploy' | 'activate' + protocolVersion: number + generation: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + componentReadiness: Record + errorCode: string | null + errorMessage: string | null + idempotencyKey: string | null + requestHash: string + actorId: string + completedAt: Date | null + createdAt: Date + updatedAt: Date + }> = {} +) { + return { + id: 'operation-1', + workflowId: WORKFLOW_ID, + deploymentVersionId: 'version-2', + version: 2, + previousActiveVersionId: 'version-1', + action: 'deploy' as const, + protocolVersion: 2, + generation: 2, + status: 'preparing' as const, + componentReadiness: {}, + errorCode: null, + errorMessage: null, + idempotencyKey: null, + requestHash: 'request-hash', + actorId: 'user-1', + completedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } +} + +function workflowState() { + return { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: NOW.getTime(), + } +} + +function scriptPrepare(params: { + operation: ReturnType + activeVersionId: string | null + maxVersion: number + maxGeneration: number +}) { + dbChainMockFns.for.mockResolvedValueOnce([{ id: WORKFLOW_ID, archivedAt: null }]) + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(params.activeVersionId ? [{ id: params.activeVersionId }] : []) + .mockResolvedValueOnce([{ maxVersion: params.maxVersion }]) + .mockResolvedValueOnce([{ maxGeneration: params.maxGeneration }]) + dbChainMockFns.returning.mockResolvedValueOnce([params.operation]) +} + +describe('deployment operation persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReset() + }) + + it('creates first-deploy and redeploy snapshots inactive while generations supersede rapidly', async () => { + const firstOperation = operationRow({ + id: 'operation-1', + deploymentVersionId: 'version-1', + version: 1, + previousActiveVersionId: null, + generation: 1, + }) + const secondOperation = operationRow({ + id: 'operation-2', + deploymentVersionId: 'version-2', + version: 2, + previousActiveVersionId: 'version-live', + generation: 2, + }) + mockGenerateId + .mockReturnValueOnce('version-1') + .mockReturnValueOnce('operation-1') + .mockReturnValueOnce('version-2') + .mockReturnValueOnce('operation-2') + scriptPrepare({ + operation: firstOperation, + activeVersionId: null, + maxVersion: 0, + maxGeneration: 0, + }) + scriptPrepare({ + operation: secondOperation, + activeVersionId: 'version-live', + maxVersion: 1, + maxGeneration: 1, + }) + + const first = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'hash-1', + idempotencyKey: 'deploy-1', + workflowState: workflowState(), + readinessComponents: ['webhooks'], + }) + const second = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'hash-2', + idempotencyKey: 'deploy-2', + workflowState: workflowState(), + readinessComponents: ['webhooks'], + }) + + expect(first).toEqual({ success: true, operation: firstOperation, reused: false }) + expect(second).toEqual({ success: true, operation: secondOperation, reused: false }) + + const insertedValues = dbChainMockFns.values.mock.calls.map(([values]) => values) + expect(insertedValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'version-1', + version: 1, + isActive: false, + }), + expect.objectContaining({ + id: 'operation-1', + generation: 1, + previousActiveVersionId: null, + status: 'preparing', + }), + expect.objectContaining({ + id: 'version-2', + version: 2, + isActive: false, + }), + expect.objectContaining({ + id: 'operation-2', + generation: 2, + previousActiveVersionId: 'version-live', + status: 'preparing', + }), + ]) + ) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ status: 'superseded' }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflow) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflowDeploymentVersion) + }) + + it('runs the prepare callback in the operation transaction after insertion', async () => { + const operation = operationRow({ + deploymentVersionId: 'version-1', + version: 1, + previousActiveVersionId: null, + generation: 1, + }) + const onPrepareTransaction = vi.fn().mockResolvedValue(undefined) + mockGenerateId.mockReturnValueOnce('version-1').mockReturnValueOnce('operation-1') + scriptPrepare({ + operation, + activeVersionId: null, + maxVersion: 0, + maxGeneration: 0, + }) + + const result = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'hash-1', + idempotencyKey: 'deploy-1', + workflowState: workflowState(), + readinessComponents: ['webhooks'], + onPrepareTransaction, + }) + + expect(result.success).toBe(true) + expect(onPrepareTransaction).toHaveBeenCalledWith( + expect.objectContaining({ insert: expect.any(Function) }), + operation + ) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + }) + + it('rejects reuse of an idempotency key with a different request hash', async () => { + dbChainMockFns.for.mockResolvedValueOnce([{ id: WORKFLOW_ID, archivedAt: null }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + operationRow({ idempotencyKey: 'deploy-1', requestHash: 'original-hash' }), + ]) + + const result = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'different-hash', + idempotencyKey: 'deploy-1', + workflowState: workflowState(), + }) + + expect(result).toEqual({ + success: false, + reason: 'idempotency_conflict', + error: 'Idempotency key was already used for a different deployment request', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('reuses an in-flight operation for a duplicate request', async () => { + const inFlight = operationRow({ + idempotencyKey: 'deploy-1', + requestHash: 'hash-1', + status: 'preparing', + }) + dbChainMockFns.for.mockResolvedValueOnce([{ id: WORKFLOW_ID, archivedAt: null }]) + dbChainMockFns.limit.mockResolvedValueOnce([inFlight]) + + const result = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'hash-1', + idempotencyKey: 'deploy-1', + workflowState: workflowState(), + }) + + expect(result).toEqual({ success: true, operation: inFlight, reused: true }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('releases a spent idempotency key and admits a fresh attempt after a failed operation', async () => { + const failed = operationRow({ + id: 'operation-failed', + idempotencyKey: 'deploy-1', + requestHash: 'hash-1', + status: 'failed', + generation: 2, + }) + const fresh = operationRow({ id: 'operation-fresh', generation: 3 }) + mockGenerateId.mockReturnValueOnce('version-fresh').mockReturnValueOnce('operation-fresh') + dbChainMockFns.for.mockResolvedValueOnce([{ id: WORKFLOW_ID, archivedAt: null }]) + dbChainMockFns.limit + .mockResolvedValueOnce([failed]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ maxVersion: 2 }]) + .mockResolvedValueOnce([{ maxGeneration: 2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([fresh]) + + const result = await prepareWorkflowDeployment({ + workflowId: WORKFLOW_ID, + actorId: 'user-1', + requestHash: 'hash-1', + idempotencyKey: 'deploy-1', + workflowState: workflowState(), + readinessComponents: ['webhooks'], + }) + + expect(result).toEqual({ success: true, operation: fresh, reused: false }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ idempotencyKey: null }) + ) + expect(dbChainMockFns.insert).toHaveBeenCalled() + }) + + it('rejects stale generation callbacks before mutating legacy deployment state', async () => { + dbChainMockFns.for + .mockResolvedValueOnce([{ id: WORKFLOW_ID }]) + .mockResolvedValueOnce([ + operationRow({ status: 'activating', generation: 4, componentReadiness: {} }), + ]) + dbChainMockFns.limit.mockResolvedValueOnce([{ maxGeneration: 5 }]) + + const result = await activateDeploymentOperation({ + workflowId: WORKFLOW_ID, + operationId: 'operation-1', + generation: 4, + }) + + expect(result).toEqual({ + success: false, + reason: 'stale_generation', + error: 'Deployment operation generation is stale', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('uses operation and generation CAS for component readiness', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await markDeploymentComponentReadiness({ + workflowId: WORKFLOW_ID, + operationId: 'operation-1', + generation: 1, + component: 'webhooks', + status: 'ready', + }) + + expect(result).toEqual({ + success: false, + reason: 'stale_generation', + error: 'Deployment operation generation or component state is stale', + }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.workflowDeploymentOperation) + }) + + it('marks failure without changing the previously active deployment', async () => { + const failedOperation = operationRow({ + status: 'failed', + errorCode: 'provider_failed', + errorMessage: 'authorization=[redacted]', + completedAt: NOW, + }) + dbChainMockFns.returning.mockResolvedValueOnce([failedOperation]) + + const result = await markDeploymentOperationFailed({ + workflowId: WORKFLOW_ID, + operationId: 'operation-1', + generation: 2, + errorCode: 'provider_failed', + error: new Error('authorization=secret'), + }) + + expect(result).toEqual({ success: true, operation: failedOperation }) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.workflowDeploymentOperation) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflow) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflowDeploymentVersion) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + errorCode: 'provider_failed', + errorMessage: 'authorization=[redacted]', + }) + ) + }) + + it('refuses activation while any required component is pending', async () => { + const operation = operationRow({ + status: 'activating', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: NOW.toISOString() }, + schedules: { status: 'pending', updatedAt: NOW.toISOString() }, + }, + }) + dbChainMockFns.for + .mockResolvedValueOnce([{ id: WORKFLOW_ID }]) + .mockResolvedValueOnce([operation]) + dbChainMockFns.limit.mockResolvedValueOnce([{ maxGeneration: 2 }]) + + const result = await activateDeploymentOperation({ + workflowId: WORKFLOW_ID, + operationId: operation.id, + generation: operation.generation, + }) + + expect(result).toEqual({ + success: false, + reason: 'not_ready', + error: 'Deployment operation components are not all ready', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('atomically flips compatibility fields only for an all-ready current operation', async () => { + const operation = operationRow({ + status: 'activating', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: NOW.toISOString() }, + schedules: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + dbChainMockFns.for + .mockResolvedValueOnce([{ id: WORKFLOW_ID }]) + .mockResolvedValueOnce([operation]) + dbChainMockFns.limit + .mockResolvedValueOnce([{ maxGeneration: 2 }]) + .mockResolvedValueOnce([{ id: 'version-1' }]) + .mockResolvedValueOnce([{ id: 'version-2' }]) + const onActivateTransaction = vi.fn().mockResolvedValue(undefined) + + const result = await activateDeploymentOperation({ + workflowId: WORKFLOW_ID, + operationId: operation.id, + generation: operation.generation, + onActivateTransaction, + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + schemaMock.workflowDeploymentVersion, + schemaMock.workflowDeploymentVersion, + schemaMock.workflow, + schemaMock.workflowDeploymentOperation, + ]) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith(1, { isActive: false }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith(2, { isActive: true }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ isDeployed: true, deployedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + status: 'active', + completedAt: expect.any(Date), + }) + ) + expect(onActivateTransaction).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + }) + + it('does not activate after a legacy writer changes the active deployment', async () => { + const operation = operationRow({ + status: 'activating', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: NOW.toISOString() }, + }, + }) + dbChainMockFns.for + .mockResolvedValueOnce([{ id: WORKFLOW_ID }]) + .mockResolvedValueOnce([operation]) + dbChainMockFns.limit + .mockResolvedValueOnce([{ maxGeneration: operation.generation }]) + .mockResolvedValueOnce([{ id: 'version-written-by-legacy-pod' }]) + + const result = await activateDeploymentOperation({ + workflowId: WORKFLOW_ID, + operationId: operation.id, + generation: operation.generation, + }) + + expect(result).toEqual({ + success: false, + reason: 'stale_generation', + error: 'Active deployment changed while this operation was preparing', + }) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.workflowDeploymentOperation) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'superseded', completedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflowDeploymentVersion) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.workflow) + }) +}) diff --git a/apps/sim/lib/workflows/persistence/deployment-operations.ts b/apps/sim/lib/workflows/persistence/deployment-operations.ts new file mode 100644 index 00000000000..f2964dfd5e0 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/deployment-operations.ts @@ -0,0 +1,912 @@ +import { db, workflow, workflowDeploymentOperation, workflowDeploymentVersion } from '@sim/db' +import { generateId } from '@sim/utils/id' +import type { DbOrTx } from '@sim/workflow-persistence/types' +import type { WorkflowState } from '@sim/workflow-types/workflow' +import type { InferSelectModel } from 'drizzle-orm' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { + canTransitionDeploymentOperation, + createDeploymentReadiness, + DEPLOYMENT_OPERATION_PROTOCOL_VERSION, + type DeploymentComponentStatus, + type DeploymentOperationAction, + type DeploymentOperationStatus, + type DeploymentReadiness, + isDeploymentOperationAction, + isDeploymentOperationStatus, + isDeploymentReadinessComplete, + parseDeploymentReadiness, + toSafeDeploymentError, +} from '@/lib/workflows/deployment-lifecycle' + +export type WorkflowDeploymentOperation = InferSelectModel + +type PrepareFailureReason = + | 'workflow_not_found' + | 'workflow_archived' + | 'deployment_version_not_found' + | 'idempotency_conflict' + | 'invalid_request' + +type MutationFailureReason = + | 'operation_not_found' + | 'invalid_transition' + | 'stale_generation' + | 'invalid_readiness' + | 'not_ready' + | 'deployment_version_not_found' + +export type PrepareDeploymentOperationResult = + | { success: true; operation: WorkflowDeploymentOperation; reused: boolean } + | { success: false; reason: PrepareFailureReason; error: string } + +export type DeploymentOperationMutationResult = + | { success: true; operation: WorkflowDeploymentOperation } + | { success: false; reason: MutationFailureReason; error: string } + +interface PrepareOperationBase { + workflowId: string + actorId: string + requestHash: string + idempotencyKey?: string + readinessComponents?: readonly string[] + tx?: DbOrTx + onPrepareTransaction?: (tx: DbOrTx, operation: WorkflowDeploymentOperation) => Promise +} + +export interface PrepareWorkflowDeploymentParams extends PrepareOperationBase { + workflowState: WorkflowState + name?: string | null + description?: string | null +} + +export interface PrepareWorkflowVersionActivationParams extends PrepareOperationBase { + deploymentVersionId: string +} + +export interface DeploymentOperationGeneration { + workflowId: string + operationId: string + generation: number +} + +export interface WorkflowDeploymentStatus { + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: Date + } | null + latestOperation: WorkflowDeploymentOperation | null +} + +export interface MarkDeploymentComponentReadinessParams extends DeploymentOperationGeneration { + component: string + status: DeploymentComponentStatus + expectedStatus?: DeploymentComponentStatus +} + +export interface ActivateDeploymentOperationParams extends DeploymentOperationGeneration { + onActivateTransaction?: (tx: DbOrTx, operation: WorkflowDeploymentOperation) => Promise +} + +interface PrepareOperationContext { + tx: DbOrTx + now: Date + actorId: string + workflowId: string +} + +interface OperationTarget { + deploymentVersionId: string + version: number +} + +type ResolveOperationTarget = (context: PrepareOperationContext) => + | Promise< + | { success: true; target: OperationTarget } + | { success: false; reason: PrepareFailureReason; error: string } + > + | { + success: true + target: OperationTarget + } + | { success: false; reason: PrepareFailureReason; error: string } + +const IN_FLIGHT_STATUSES: DeploymentOperationStatus[] = ['preparing', 'activating'] + +/** + * Creates an inactive immutable snapshot and a preparing deployment attempt. + */ +export async function prepareWorkflowDeployment( + params: PrepareWorkflowDeploymentParams +): Promise { + return prepareOperation(params, 'deploy', async ({ tx, now, actorId, workflowId }) => { + const [{ maxVersion }] = await tx + .select({ maxVersion: sql`COALESCE(MAX(${workflowDeploymentVersion.version}), 0)` }) + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + .limit(1) + const version = Number(maxVersion) + 1 + const deploymentVersionId = generateId() + + await tx.insert(workflowDeploymentVersion).values({ + id: deploymentVersionId, + workflowId, + version, + name: params.name?.trim() || null, + description: params.description?.trim() || null, + state: params.workflowState, + isActive: false, + createdAt: now, + createdBy: actorId, + }) + + return { + success: true, + target: { deploymentVersionId, version }, + } + }) +} + +/** + * Creates a preparing attempt targeting an existing immutable snapshot. + */ +export async function prepareWorkflowVersionActivation( + params: PrepareWorkflowVersionActivationParams +): Promise { + return prepareOperation(params, 'activate', async ({ tx, workflowId }) => { + const [versionRow] = await tx + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + createdAt: workflowDeploymentVersion.createdAt, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.id, params.deploymentVersionId) + ) + ) + .limit(1) + + if (!versionRow) { + return { + success: false, + reason: 'deployment_version_not_found', + error: 'Deployment version not found', + } + } + + return { + success: true, + target: { + deploymentVersionId: versionRow.id, + version: versionRow.version, + }, + } + }) +} + +/** + * Returns the active immutable version and latest deployment attempt for a workflow. + */ +export async function getWorkflowDeploymentStatus( + workflowId: string +): Promise { + const [[activeVersion], [workflowRow], [latestOperation]] = await Promise.all([ + db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + createdAt: workflowDeploymentVersion.createdAt, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.version)) + .limit(1), + db + .select({ deployedAt: workflow.deployedAt }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + db + .select() + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, workflowId)) + .orderBy(desc(workflowDeploymentOperation.generation)) + .limit(1), + ]) + + return { + activeDeployment: activeVersion + ? { + deploymentVersionId: activeVersion.id, + version: activeVersion.version, + deployedAt: workflowRow?.deployedAt ?? activeVersion.createdAt, + } + : null, + latestOperation: latestOperation ?? null, + } +} + +/** + * Loads one generation-scoped operation for an outbox worker. + */ +export async function getDeploymentOperation( + params: DeploymentOperationGeneration +): Promise { + const [operation] = await db + .select() + .from(workflowDeploymentOperation) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation) + ) + ) + .limit(1) + + return operation ?? null +} + +/** + * Confirms an operation still owns the workflow's latest generation. + */ +export async function isDeploymentOperationCurrent( + params: DeploymentOperationGeneration & { + deploymentVersionId?: string + statuses?: readonly DeploymentOperationStatus[] + }, + executor: Pick = db +): Promise { + const [latestOperation] = await executor + .select({ + id: workflowDeploymentOperation.id, + generation: workflowDeploymentOperation.generation, + deploymentVersionId: workflowDeploymentOperation.deploymentVersionId, + status: workflowDeploymentOperation.status, + }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, params.workflowId)) + .orderBy(desc(workflowDeploymentOperation.generation)) + .limit(1) + + if ( + !latestOperation || + latestOperation.id !== params.operationId || + latestOperation.generation !== params.generation || + (params.deploymentVersionId !== undefined && + latestOperation.deploymentVersionId !== params.deploymentVersionId) + ) { + return false + } + if (!params.statuses) return true + if (!isDeploymentOperationStatus(latestOperation.status)) return false + return params.statuses.includes(latestOperation.status) +} + +/** + * Protects an inactive v2 candidate from rolling-deploy v1 cleanup workers. + */ +export async function isDeploymentVersionProtectedByCurrentOperation( + workflowId: string, + deploymentVersionId: string, + executor: Pick = db +): Promise { + const [latestOperation] = await executor + .select({ + deploymentVersionId: workflowDeploymentOperation.deploymentVersionId, + protocolVersion: workflowDeploymentOperation.protocolVersion, + status: workflowDeploymentOperation.status, + }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, workflowId)) + .orderBy(desc(workflowDeploymentOperation.generation)) + .limit(1) + + return ( + latestOperation?.deploymentVersionId === deploymentVersionId && + latestOperation.protocolVersion === DEPLOYMENT_OPERATION_PROTOCOL_VERSION && + isDeploymentOperationStatus(latestOperation.status) && + IN_FLIGHT_STATUSES.includes(latestOperation.status) + ) +} + +/** + * Moves the current preparing generation into its activation phase. + */ +export async function beginDeploymentOperationActivation( + params: DeploymentOperationGeneration +): Promise { + return db.transaction(async (tx) => { + const operation = await lockCurrentOperation(tx, params) + if (!operation.success) return operation + + if ( + !isDeploymentOperationStatus(operation.operation.status) || + !canTransitionDeploymentOperation(operation.operation.status, 'activating') + ) { + return { + success: false, + reason: 'invalid_transition', + error: `Cannot transition deployment operation from ${operation.operation.status} to activating`, + } + } + + const now = new Date() + await tx + .update(workflowDeploymentOperation) + .set({ + status: 'activating', + errorCode: null, + errorMessage: null, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation), + eq(workflowDeploymentOperation.status, 'preparing') + ) + ) + + return { + success: true, + operation: { + ...operation.operation, + status: 'activating', + errorCode: null, + errorMessage: null, + updatedAt: now, + }, + } + }) +} + +/** + * Atomically updates one declared component using operation, generation, and + * component-state compare-and-swap predicates. + */ +export async function markDeploymentComponentReadiness( + params: MarkDeploymentComponentReadinessParams +): Promise { + const component = params.component.trim() + if (!component) { + return { + success: false, + reason: 'invalid_readiness', + error: 'Deployment readiness component name cannot be empty', + } + } + + const now = new Date() + const expectedStatus = params.expectedStatus ?? 'pending' + const nextState = { + status: params.status, + updatedAt: now.toISOString(), + } + + const [updated] = await db + .update(workflowDeploymentOperation) + .set({ + componentReadiness: sql`jsonb_set( + ${workflowDeploymentOperation.componentReadiness}, + ARRAY[${component}]::text[], + ${JSON.stringify(nextState)}::jsonb, + false + )`, + errorCode: null, + errorMessage: null, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation), + inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES), + sql`${workflowDeploymentOperation.componentReadiness} ? ${component}`, + sql`${workflowDeploymentOperation.componentReadiness} -> ${component} ->> 'status' = ${expectedStatus}` + ) + ) + .returning() + + if (!updated) { + return { + success: false, + reason: 'stale_generation', + error: 'Deployment operation generation or component state is stale', + } + } + + return { success: true, operation: updated } +} + +/** + * Atomically activates an all-ready current operation and projects it onto the + * legacy workflow/version fields. + */ +export async function activateDeploymentOperation( + params: ActivateDeploymentOperationParams +): Promise { + return db.transaction(async (tx) => { + const operationResult = await lockCurrentOperation(tx, params) + if (!operationResult.success) return operationResult + + const operation = operationResult.operation + if ( + !isDeploymentOperationStatus(operation.status) || + !canTransitionDeploymentOperation(operation.status, 'active') + ) { + return { + success: false, + reason: 'invalid_transition', + error: `Cannot transition deployment operation from ${operation.status} to active`, + } + } + + const readiness = parseDeploymentReadiness(operation.componentReadiness) + if (!readiness) { + return { + success: false, + reason: 'invalid_readiness', + error: 'Deployment operation readiness is invalid', + } + } + if (!isDeploymentReadinessComplete(readiness)) { + return { + success: false, + reason: 'not_ready', + error: 'Deployment operation components are not all ready', + } + } + + const [currentActiveVersion] = await tx + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, operation.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.version)) + .limit(1) + if ((currentActiveVersion?.id ?? null) !== operation.previousActiveVersionId) { + const supersededAt = new Date() + await tx + .update(workflowDeploymentOperation) + .set({ + status: 'superseded', + completedAt: supersededAt, + updatedAt: supersededAt, + }) + .where( + and( + eq(workflowDeploymentOperation.id, operation.id), + eq(workflowDeploymentOperation.workflowId, operation.workflowId), + eq(workflowDeploymentOperation.generation, operation.generation), + eq(workflowDeploymentOperation.status, 'activating') + ) + ) + return { + success: false, + reason: 'stale_generation', + error: 'Active deployment changed while this operation was preparing', + } + } + + if (!operation.deploymentVersionId) { + return { + success: false, + reason: 'deployment_version_not_found', + error: 'Deployment operation has no target version', + } + } + + const [targetVersion] = await tx + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, operation.workflowId), + eq(workflowDeploymentVersion.id, operation.deploymentVersionId) + ) + ) + .limit(1) + if (!targetVersion) { + return { + success: false, + reason: 'deployment_version_not_found', + error: 'Deployment version not found', + } + } + + const now = new Date() + if (!isDeploymentOperationAction(operation.action)) { + return { + success: false, + reason: 'invalid_transition', + error: `Deployment operation action is invalid: ${operation.action}`, + } + } + + await tx + .update(workflowDeploymentVersion) + .set({ isActive: false }) + .where(eq(workflowDeploymentVersion.workflowId, operation.workflowId)) + + await tx + .update(workflowDeploymentVersion) + .set({ isActive: true }) + .where( + and( + eq(workflowDeploymentVersion.workflowId, operation.workflowId), + eq(workflowDeploymentVersion.id, operation.deploymentVersionId) + ) + ) + + await tx + .update(workflow) + .set({ + isDeployed: true, + deployedAt: now, + }) + .where(eq(workflow.id, operation.workflowId)) + + await tx + .update(workflowDeploymentOperation) + .set({ + status: 'active', + errorCode: null, + errorMessage: null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.id, operation.id), + eq(workflowDeploymentOperation.generation, operation.generation), + eq(workflowDeploymentOperation.status, 'activating') + ) + ) + + const activatedOperation: WorkflowDeploymentOperation = { + ...operation, + status: 'active', + errorCode: null, + errorMessage: null, + completedAt: now, + updatedAt: now, + } + await params.onActivateTransaction?.(tx, activatedOperation) + + return { success: true, operation: activatedOperation } + }) +} + +/** + * Marks an in-flight operation failed without changing the live deployment. + */ +export async function markDeploymentOperationFailed( + params: DeploymentOperationGeneration & { error: unknown; errorCode?: string } +): Promise { + const safeError = toSafeDeploymentError(params.error, params.errorCode) + return markDeploymentOperationTerminal(params, 'failed', safeError) +} + +/** + * Records a transient attempt failure on an in-flight operation without + * changing its lifecycle status, so status surfaces can show "retrying" with + * the live error instead of a blank pending state. Cleared again the moment a + * later attempt makes progress (component readiness or activation). + */ +export async function recordDeploymentOperationRetry( + params: DeploymentOperationGeneration & { error: unknown } +): Promise { + const safeError = toSafeDeploymentError(params.error, 'preparation_retrying') + const now = new Date() + await db + .update(workflowDeploymentOperation) + .set({ + errorCode: safeError.code, + errorMessage: safeError.message, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation), + inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES) + ) + ) +} + +/** + * Supersedes every in-flight operation for a workflow. Must run inside the + * undeploy/archive transaction so a queued preparation cannot activate a + * version after the user explicitly took the workflow offline. + */ +export async function supersedeInFlightDeploymentOperations( + executor: DbOrTx, + workflowId: string +): Promise { + const now = new Date() + await executor + .update(workflowDeploymentOperation) + .set({ + status: 'superseded', + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.workflowId, workflowId), + inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES) + ) + ) +} + +async function prepareOperation( + params: PrepareOperationBase, + action: DeploymentOperationAction, + resolveTarget: ResolveOperationTarget +): Promise { + const actorId = params.actorId.trim() + const requestHash = params.requestHash.trim() + const idempotencyKey = params.idempotencyKey?.trim() || null + if (!actorId || !requestHash) { + return { + success: false, + reason: 'invalid_request', + error: 'Deployment operation actor and request hash are required', + } + } + + let readiness: DeploymentReadiness + try { + readiness = createDeploymentReadiness(params.readinessComponents ?? []) + } catch (error) { + return { + success: false, + reason: 'invalid_request', + error: toSafeDeploymentError(error, 'invalid_readiness').message, + } + } + + const executePrepare = async (tx: DbOrTx): Promise => { + const now = new Date() + const [workflowRow] = await tx + .select({ + id: workflow.id, + archivedAt: workflow.archivedAt, + }) + .from(workflow) + .where(eq(workflow.id, params.workflowId)) + .for('update') + if (!workflowRow) { + return { + success: false, + reason: 'workflow_not_found', + error: 'Workflow not found', + } + } + if (workflowRow.archivedAt) { + return { + success: false, + reason: 'workflow_archived', + error: 'Cannot change deployment state for an archived workflow', + } + } + + if (idempotencyKey) { + const [existing] = await tx + .select() + .from(workflowDeploymentOperation) + .where( + and( + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.idempotencyKey, idempotencyKey) + ) + ) + .limit(1) + if (existing) { + if (existing.requestHash !== requestHash) { + return { + success: false, + reason: 'idempotency_conflict', + error: 'Idempotency key was already used for a different deployment request', + } + } + if (existing.status !== 'failed' && existing.status !== 'superseded') { + return { success: true, operation: existing, reused: true } + } + /** + * A terminally failed or superseded attempt releases its idempotency + * key: duplicate-submission protection must never pin a retry to a + * spent attempt — the caller would get success with no live work + * behind it. The key moves to the fresh operation created below so + * later duplicates of the same request reuse that one instead. + */ + await tx + .update(workflowDeploymentOperation) + .set({ idempotencyKey: null, updatedAt: now }) + .where(eq(workflowDeploymentOperation.id, existing.id)) + } + } + + const [currentActiveVersion] = await tx + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, params.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.version)) + .limit(1) + + const targetResult = await resolveTarget({ + tx, + now, + actorId, + workflowId: params.workflowId, + }) + if (!targetResult.success) return targetResult + + const [{ maxGeneration }] = await tx + .select({ + maxGeneration: sql`COALESCE(MAX(${workflowDeploymentOperation.generation}), 0)`, + }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, params.workflowId)) + .limit(1) + const generation = Number(maxGeneration) + 1 + + await tx + .update(workflowDeploymentOperation) + .set({ + status: 'superseded', + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.workflowId, params.workflowId), + inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES) + ) + ) + + const [operation] = await tx + .insert(workflowDeploymentOperation) + .values({ + id: generateId(), + workflowId: params.workflowId, + deploymentVersionId: targetResult.target.deploymentVersionId, + version: targetResult.target.version, + previousActiveVersionId: currentActiveVersion?.id ?? null, + action, + protocolVersion: DEPLOYMENT_OPERATION_PROTOCOL_VERSION, + generation, + status: 'preparing', + componentReadiness: readiness, + errorCode: null, + errorMessage: null, + idempotencyKey, + requestHash, + actorId, + completedAt: null, + createdAt: now, + updatedAt: now, + }) + .returning() + + if (!operation) { + throw new Error('Failed to create deployment operation') + } + await params.onPrepareTransaction?.(tx, operation) + return { success: true, operation, reused: false } + } + + return params.tx ? executePrepare(params.tx) : db.transaction(executePrepare) +} + +async function lockCurrentOperation( + tx: DbOrTx, + params: DeploymentOperationGeneration +): Promise { + const [workflowRow] = await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, params.workflowId)) + .for('update') + if (!workflowRow) { + return { + success: false, + reason: 'operation_not_found', + error: 'Deployment operation workflow not found', + } + } + + const [operation] = await tx + .select() + .from(workflowDeploymentOperation) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation) + ) + ) + .for('update') + if (!operation) { + return { + success: false, + reason: 'operation_not_found', + error: 'Deployment operation not found', + } + } + + const [{ maxGeneration }] = await tx + .select({ + maxGeneration: sql`COALESCE(MAX(${workflowDeploymentOperation.generation}), 0)`, + }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, operation.workflowId)) + .limit(1) + if (Number(maxGeneration) !== params.generation) { + return { + success: false, + reason: 'stale_generation', + error: 'Deployment operation generation is stale', + } + } + + return { success: true, operation } +} + +async function markDeploymentOperationTerminal( + params: DeploymentOperationGeneration, + status: Extract, + error?: { code: string; message: string } +): Promise { + const now = new Date() + const [updated] = await db + .update(workflowDeploymentOperation) + .set({ + status, + errorCode: error?.code ?? null, + errorMessage: error?.message ?? null, + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workflowDeploymentOperation.id, params.operationId), + eq(workflowDeploymentOperation.workflowId, params.workflowId), + eq(workflowDeploymentOperation.generation, params.generation), + inArray(workflowDeploymentOperation.status, IN_FLIGHT_STATUSES) + ) + ) + .returning() + + if (!updated) { + return { + success: false, + reason: 'invalid_transition', + error: `Deployment operation cannot transition to ${status}`, + } + } + + return { success: true, operation: updated } +} diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index 964577f9cf8..c719913e6cb 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -111,6 +111,15 @@ vi.mock('@sim/db', () => ({ }, workflow: {}, webhook: {}, + workflowDeploymentOperation: { + workflowId: 'workflowId', + status: 'status', + }, + workflowSchedule: { + workflowId: 'workflowId', + deploymentVersionId: 'deploymentVersionId', + archivedAt: 'archivedAt', + }, })) const { mockSanitizeAgentToolsInBlocks } = vi.hoisted(() => ({ @@ -869,35 +878,60 @@ describe('Database Helpers', () => { } } - it('returns not_found when deploy cannot lock a workflow row', async () => { - const { tx, lockFor } = createMissingWorkflowTx() + it('returns an error when undeploy cannot lock a workflow row', async () => { + const { tx, update } = createMissingWorkflowTx() mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) - const result = await dbHelpers.deployWorkflow({ - workflowId: mockWorkflowId, - deployedBy: 'user-123', - }) + const result = await dbHelpers.undeployWorkflow({ workflowId: mockWorkflowId }) expect(result).toEqual({ success: false, error: 'Workflow not found', - errorCode: 'not_found', }) - expect(lockFor).toHaveBeenCalledWith('update') - expect(tx.execute).not.toHaveBeenCalled() + expect(update).not.toHaveBeenCalled() }) - it('returns an error when undeploy cannot lock a workflow row', async () => { - const { tx, update } = createMissingWorkflowTx() + it('supersedes in-flight operations and releases path claims during undeploy', async () => { + const versionRows = [{ id: 'dv-1' }, { id: 'dv-2' }] + const createWhereResult = () => ({ + limit: vi.fn(() => ({ + for: vi.fn().mockResolvedValue([{ id: mockWorkflowId }]), + })), + then: (resolve: (rows: typeof versionRows) => void) => resolve(versionRows), + }) + const setCalls: unknown[] = [] + const tx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ where: vi.fn(() => createWhereResult()) })), + })), + update: vi.fn(() => ({ + set: vi.fn((payload: unknown) => { + setCalls.push(payload) + return { where: vi.fn().mockResolvedValue([]) } + }), + })), + delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), + } mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) + const onUndeployTransaction = vi.fn().mockResolvedValue(undefined) - const result = await dbHelpers.undeployWorkflow({ workflowId: mockWorkflowId }) + const result = await dbHelpers.undeployWorkflow({ + workflowId: mockWorkflowId, + onUndeployTransaction, + }) - expect(result).toEqual({ - success: false, - error: 'Workflow not found', + expect(result).toEqual({ success: true }) + expect(setCalls[0]).toEqual(expect.objectContaining({ status: 'superseded' })) + expect(setCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ isActive: false }), + expect.objectContaining({ isDeployed: false, deployedAt: null }), + ]) + ) + expect(tx.delete).toHaveBeenCalledTimes(2) + expect(onUndeployTransaction).toHaveBeenCalledWith(tx, { + deploymentVersionIds: ['dv-1', 'dv-2'], }) - expect(update).not.toHaveBeenCalled() }) }) @@ -1690,6 +1724,25 @@ describe('Database Helpers', () => { expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(2) }) + it('loads an admitted immutable deployment version even after a later cutover', async () => { + const state = buildDeployedState() + const limit = vi.fn().mockResolvedValue([{ id: 'dv-admitted', state }]) + const where = vi.fn().mockReturnValue({ limit }) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ where }), + }) + + const result = await dbHelpers.loadWorkflowDeploymentVersionState( + 'wf-admitted', + 'dv-admitted', + 'workspace-1' + ) + + expect(result.deploymentVersionId).toBe('dv-admitted') + expect(result.blocks).toEqual(state.blocks) + expect(where).toHaveBeenCalledTimes(1) + }) + it('invalidateDeployedStateCache(id) forces a rebuild on the next call', async () => { mockActiveVersionSelect('dv-inv', buildDeployedState()) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 8f3df9326d4..1de604a8d80 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1,4 +1,10 @@ -import { db, runOutsideTransactionContext, workflow, workflowDeploymentVersion } from '@sim/db' +import { + db, + runOutsideTransactionContext, + workflow, + workflowDeploymentOperation, + workflowDeploymentVersion, +} from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' @@ -15,11 +21,13 @@ import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm' import { LRUCache } from 'lru-cache' import type { Edge } from 'reactflow' +import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { backfillCanonicalModes, migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' +import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' const logger = createLogger('WorkflowDBHelpers') @@ -65,6 +73,7 @@ export interface WorkflowDeploymentVersionResponse { createdAt: string createdBy?: string | null deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null } export interface DeployedWorkflowData extends NormalizedWorkflowData { @@ -123,6 +132,50 @@ export function invalidateDeployedStateCache(deploymentVersionId?: string): void deployedStateCache.clear() } +interface DeploymentStateRow { + id: string + state: unknown +} + +async function materializeDeploymentState( + workflowId: string, + version: DeploymentStateRow, + providedWorkspaceId?: string +): Promise { + const cached = deployedStateCache.get(version.id) + if (cached) { + return structuredClone(cached) + } + + const state = version.state as WorkflowState & { variables?: Record } + let resolvedWorkspaceId = providedWorkspaceId + if (!resolvedWorkspaceId) { + const workflowContext = await getActiveWorkflowContext(workflowId) + resolvedWorkspaceId = workflowContext?.workspaceId + } + + if (!resolvedWorkspaceId) { + throw new Error(`Workflow ${workflowId} has no workspace`) + } + + const { blocks: migratedBlocks } = await applyBlockMigrations( + state.blocks || {}, + resolvedWorkspaceId + ) + const deployedState: DeployedWorkflowData = { + blocks: migratedBlocks, + edges: state.edges || [], + loops: state.loops || {}, + parallels: state.parallels || {}, + variables: state.variables || {}, + isFromNormalizedTables: false, + deploymentVersionId: version.id, + } + + deployedStateCache.set(version.id, deployedState) + return structuredClone(deployedState) +} + export async function loadDeployedWorkflowState( workflowId: string, providedWorkspaceId?: string @@ -148,47 +201,42 @@ export async function loadDeployedWorkflowState( throw new Error(`Workflow ${workflowId} has no active deployment`) } - const cached = deployedStateCache.get(active.id) - if (cached) { - return structuredClone(cached) - } - - const state = active.state as WorkflowState & { variables?: Record } - - let resolvedWorkspaceId = providedWorkspaceId - if (!resolvedWorkspaceId) { - const workflowContext = await getActiveWorkflowContext(workflowId) - resolvedWorkspaceId = workflowContext?.workspaceId - } - - if (!resolvedWorkspaceId) { - throw new Error(`Workflow ${workflowId} has no workspace`) - } - - const { blocks: migratedBlocks } = await applyBlockMigrations( - state.blocks || {}, - resolvedWorkspaceId - ) - - const deployedState: DeployedWorkflowData = { - blocks: migratedBlocks, - edges: state.edges || [], - loops: state.loops || {}, - parallels: state.parallels || {}, - variables: state.variables || {}, - isFromNormalizedTables: false, - deploymentVersionId: active.id, - } - - deployedStateCache.set(active.id, deployedState) - - return structuredClone(deployedState) + return materializeDeploymentState(workflowId, active, providedWorkspaceId) } catch (error) { logger.error(`Error loading deployed workflow state ${workflowId}:`, error) throw error } } +/** + * Loads an immutable deployment snapshot by ID for work admitted before a later cutover. + */ +export async function loadWorkflowDeploymentVersionState( + workflowId: string, + deploymentVersionId: string, + providedWorkspaceId?: string +): Promise { + const [version] = await db + .select({ + id: workflowDeploymentVersion.id, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.id, deploymentVersionId) + ) + ) + .limit(1) + + if (!version?.state) { + throw new Error(`Deployment ${deploymentVersionId} was not found for workflow ${workflowId}`) + } + + return materializeDeploymentState(workflowId, version, providedWorkspaceId) +} + interface MigrationContext { blocks: Record workspaceId: string @@ -542,10 +590,6 @@ export async function workflowExistsInNormalizedTables(workflowId: string): Prom } } -type DeployWorkflowValidationResult = - | { success: true } - | { success: false; error: string; errorCode?: 'validation' } - /** * Update the name and/or description metadata of an existing deployment version. * Shared by the workflow deployment-version PATCH route and the copilot @@ -595,198 +639,6 @@ export async function updateDeploymentVersionMetadata(params: { return updated ?? null } -export async function deployWorkflow(params: { - workflowId: string - deployedBy: string - workflowName?: string - /** Optional human-readable summary of what changed, stored on the deployment version. */ - description?: string | null - /** Optional human-readable name/label for the deployment version. */ - name?: string | null - workflowState?: WorkflowState - validateWorkflowState?: ( - workflowState: WorkflowState, - executor: DbOrTx - ) => DeployWorkflowValidationResult | Promise - onDeployTransaction?: ( - tx: DbOrTx, - result: { deploymentVersionId: string; version: number; previousVersionId?: string } - ) => Promise -}): Promise<{ - success: boolean - version?: number - deploymentVersionId?: string - deployedAt?: Date - previousVersionId?: string - currentState?: WorkflowState - error?: string - errorCode?: 'validation' | 'not_found' -}> { - const { workflowId, deployedBy, workflowName } = params - - try { - const now = new Date() - let currentState: WorkflowState | null = null - - const deployedVersion = await db.transaction(async (tx) => { - if (!(await lockWorkflowForUpdate(tx, workflowId))) { - return { - success: false as const, - error: 'Workflow not found', - errorCode: 'not_found' as const, - } - } - - // Refuse to deploy an archived (soft-deleted) workflow. Checked under the row - // lock so it's atomic with a concurrent fork rollback that archives a - // promote-created workflow: a stale promote deploy can never resurrect it into - // an archived-but-deployed (incoherent) state. - const [archivedRow] = await tx - .select({ archivedAt: workflow.archivedAt }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - if (archivedRow?.archivedAt != null) { - return { - success: false as const, - error: 'Cannot deploy an archived workflow', - errorCode: 'validation' as const, - } - } - - currentState = params.workflowState ?? (await loadWorkflowDeploymentSnapshot(workflowId, tx)) - if (!currentState) { - return { - success: false as const, - error: 'Failed to load workflow state', - errorCode: 'validation' as const, - } - } - - const validationError = await params.validateWorkflowState?.(currentState, tx) - if (validationError && !validationError.success) { - return { - success: false as const, - error: validationError.error, - errorCode: validationError.errorCode, - } - } - - const [currentActiveVersion] = await tx - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - const previousVersionId = currentActiveVersion?.id - - const [{ maxVersion }] = await tx - .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) - .from(workflowDeploymentVersion) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - - const nextVersion = Number(maxVersion) + 1 - const deploymentVersionId = generateId() - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - - await tx.insert(workflowDeploymentVersion).values({ - id: deploymentVersionId, - workflowId, - version: nextVersion, - state: currentState, - isActive: true, - createdBy: deployedBy, - createdAt: now, - description: params.description?.trim() || null, - name: params.name?.trim() || null, - }) - - const updateData: Record = { - isDeployed: true, - deployedAt: now, - } - - await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - - await params.onDeployTransaction?.(tx, { - deploymentVersionId, - version: nextVersion, - previousVersionId, - }) - - return { - success: true as const, - version: nextVersion, - deploymentVersionId, - previousVersionId, - currentState, - } - }) - - if (!deployedVersion.success) { - return { - success: false, - error: deployedVersion.error, - errorCode: deployedVersion.errorCode, - } - } - const deployedState = deployedVersion.currentState - if (!deployedState) { - return { success: false, error: 'Failed to load workflow state' } - } - - logger.info(`Deployed workflow ${workflowId} as v${deployedVersion.version}`) - - if (workflowName) { - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - - const blockTypeCounts: Record = {} - for (const block of Object.values(deployedState.blocks)) { - const blockType = block.type || 'unknown' - blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 - } - - PlatformEvents.workflowDeployed({ - workflowId, - workflowName, - blocksCount: Object.keys(deployedState.blocks).length, - edgesCount: deployedState.edges.length, - version: deployedVersion.version, - loopsCount: Object.keys(deployedState.loops).length, - parallelsCount: Object.keys(deployedState.parallels).length, - blockTypes: JSON.stringify(blockTypeCounts), - }) - } catch (telemetryError) { - logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError) - } - } - - return { - success: true, - version: deployedVersion.version, - deploymentVersionId: deployedVersion.deploymentVersionId, - previousVersionId: deployedVersion.previousVersionId, - deployedAt: now, - currentState: deployedState, - } - } catch (error) { - logger.error(`Error deploying workflow ${workflowId}:`, error) - return { - success: false, - error: getErrorMessage(error, 'Unknown error'), - } - } -} - export interface RegenerateStateInput { blocks?: Record edges?: Edge[] @@ -956,8 +808,10 @@ export async function undeployWorkflow(params: { .where(eq(workflowDeploymentVersion.workflowId, workflowId)) const deploymentVersionIds = deploymentVersions.map((version) => version.id) + await supersedeInFlightDeploymentOperations(dbCtx, workflowId) const { deleteSchedulesForWorkflow } = await import('@/lib/workflows/schedules/deploy') await deleteSchedulesForWorkflow(workflowId, dbCtx) + await releaseWebhookPathClaims(dbCtx, workflowId) await dbCtx .update(workflowDeploymentVersion) @@ -992,207 +846,6 @@ export async function undeployWorkflow(params: { } } -export async function activateWorkflowVersion(params: { - workflowId: string - version: number - onActivateTransaction?: ( - tx: DbOrTx, - result: { deploymentVersionId: string; previousVersionId?: string } - ) => Promise -}): Promise<{ - success: boolean - deployedAt?: Date - state?: unknown - previousVersionId?: string - error?: string -}> { - const { workflowId, version } = params - - try { - const now = new Date() - let versionState: unknown - - const result = await db.transaction(async (tx) => { - if (!(await lockWorkflowForUpdate(tx, workflowId))) { - return { success: false as const, error: 'Workflow not found' } - } - - const [currentActiveVersion] = await tx - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - - const [versionData] = await tx - .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) - .limit(1) - - if (!versionData) { - return { success: false as const, error: 'Deployment version not found' } - } - versionState = versionData.state - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: true }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) - - await tx - .update(workflow) - .set({ isDeployed: true, deployedAt: now }) - .where(eq(workflow.id, workflowId)) - - await params.onActivateTransaction?.(tx, { - deploymentVersionId: versionData.id, - previousVersionId: currentActiveVersion?.id, - }) - - return { success: true as const, previousVersionId: currentActiveVersion?.id } - }) - - if (!result.success) { - return { success: false, error: result.error } - } - - logger.info(`Activated version ${version} for workflow ${workflowId}`) - - return { - success: true, - deployedAt: now, - state: versionState, - previousVersionId: result.previousVersionId, - } - } catch (error) { - logger.error(`Error activating version ${version} for workflow ${workflowId}:`, error) - return { - success: false, - error: getErrorMessage(error, 'Failed to activate version'), - } - } -} - -async function activateWorkflowVersionById(params: { - workflowId: string - deploymentVersionId: string -}): Promise<{ - success: boolean - deployedAt?: Date - state?: unknown - previousVersionId?: string - error?: string -}> { - const { workflowId, deploymentVersionId } = params - - try { - const now = new Date() - let versionState: unknown - - const result = await db.transaction(async (tx) => { - if (!(await lockWorkflowForUpdate(tx, workflowId))) { - return { success: false as const, error: 'Workflow not found' } - } - - const [currentActiveVersion] = await tx - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - - const [versionData] = await tx - .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.id, deploymentVersionId) - ) - ) - .limit(1) - - if (!versionData) { - return { success: false as const, error: 'Deployment version not found' } - } - versionState = versionData.state - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: true }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.id, deploymentVersionId) - ) - ) - - await tx - .update(workflow) - .set({ isDeployed: true, deployedAt: now }) - .where(eq(workflow.id, workflowId)) - - return { success: true as const, previousVersionId: currentActiveVersion?.id } - }) - - if (!result.success) { - return { success: false, error: result.error } - } - - logger.info(`Activated deployment version ${deploymentVersionId} for workflow ${workflowId}`) - - return { - success: true, - deployedAt: now, - state: versionState, - previousVersionId: result.previousVersionId, - } - } catch (error) { - logger.error( - `Error activating deployment version ${deploymentVersionId} for workflow ${workflowId}:`, - error - ) - return { - success: false, - error: getErrorMessage(error, 'Failed to activate version'), - } - } -} - /** * Resolves the deployment version that precedes the currently active one — * the default rollback target when no explicit version is given. @@ -1284,30 +937,52 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ createdAt: Date createdBy: string | null deployedByName: string | null + latestOperationStatus: string | null }> }> { const { user } = await import('@sim/db') - const rows = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedByName: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)) + const [rows, [currentOperation]] = await Promise.all([ + db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + deployedByName: user.name, + }) + .from(workflowDeploymentVersion) + .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + .orderBy(desc(workflowDeploymentVersion.version)), + /** + * Only the workflow's current (latest-generation) operation carries a + * status marker: a failed or in-flight attempt is live information until + * the next deploy action supersedes it, at which point it is history and + * the marker clears rather than sticking to old versions forever. + */ + db + .select({ + deploymentVersionId: workflowDeploymentOperation.deploymentVersionId, + status: workflowDeploymentOperation.status, + }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, workflowId)) + .orderBy(desc(workflowDeploymentOperation.generation)) + .limit(1), + ]) return { versions: rows.map((row) => ({ ...row, deployedByName: row.deployedByName ?? (row.createdBy === 'admin-api' ? 'Admin' : null), + latestOperationStatus: + currentOperation && currentOperation.deploymentVersionId === row.id + ? currentOperation.status + : null, })), } } diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 2cbad1c1ce9..4659f0938f9 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -24,6 +24,7 @@ const listColumns = { updatedAt: workflow.updatedAt, archivedAt: workflow.archivedAt, locked: workflow.locked, + forkSyncExcluded: workflow.forkSyncExcluded, isDeployed: workflow.isDeployed, } as const @@ -40,6 +41,7 @@ type WorkflowListRow = { updatedAt: Date archivedAt: Date | null locked: boolean + forkSyncExcluded: boolean isDeployed: boolean } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts new file mode 100644 index 00000000000..72cfcde085f --- /dev/null +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** + * Builds a minimal one-block workflow whose knowledge block carries the two + * subblock keys `edit_workflow` is allowed to write. + */ +function makeKnowledgeWorkflow(tagFiltersValue: unknown) { + return { + blocks: { + 'kb-1': { + id: 'kb-1', + type: 'knowledge', + name: 'Knowledge 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + tagFilters: { id: 'tagFilters', type: 'knowledge-tag-filters', value: tagFiltersValue }, + documentTags: { + id: 'documentTags', + type: 'document-tag-entry', + value: JSON.stringify([{ id: 't1', tagName: 'Team' }]), + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +describe('sanitizeForCopilot knowledge tag subblocks', () => { + // Regression: these keys were stripped, which made them write-only for the agent -- + // edit_workflow could set a tag filter but the agent read back an absent field and + // cleared the user's filter on the next edit. + it('retains tagFilters so the agent can read back what edit_workflow writes', () => { + const value = JSON.stringify([ + { id: 'f1', tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }, + ]) + + const result = sanitizeForCopilot(makeKnowledgeWorkflow(value)) + const inputs = result.blocks['kb-1'].inputs + + expect(inputs?.tagFilters).toBe(value) + }) + + it('retains documentTags alongside tagFilters', () => { + const result = sanitizeForCopilot(makeKnowledgeWorkflow(JSON.stringify([]))) + const inputs = result.blocks['kb-1'].inputs + + expect(inputs?.documentTags).toBeDefined() + }) + + it('still omits the key when no filter is set, so absent means unset', () => { + const result = sanitizeForCopilot(makeKnowledgeWorkflow(null)) + const inputs = result.blocks['kb-1'].inputs + + expect(inputs).not.toHaveProperty('tagFilters') + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 04802b76e49..1879fae0273 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -254,6 +254,13 @@ function isToolInput(value: unknown): value is ToolInput { /** * Sanitize subblocks by removing null values and simplifying structure * Maps each subblock key directly to its value instead of the full object + * + * @remarks + * `tagFilters` and `documentTags` are deliberately retained. This is the copilot's read + * view of workflow state, and `edit_workflow` can write both keys, so dropping them here + * makes the field write-only: the agent reads back an absent field and clears the user's + * filter on the next edit. Redaction for shared/exported workflows is a separate concern, + * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( subBlocks: BlockState['subBlocks'] @@ -310,11 +317,6 @@ function sanitizeSubBlocks( return } - // Skip knowledge base tag filters and document tags (workspace-specific data) - if (key === 'tagFilters' || key === 'documentTags') { - return - } - sanitized[key] = subBlock.value }) diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index 9bbac181f7e..041bf4bbc5a 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -43,6 +43,7 @@ vi.mock('@sim/db', () => ({ workflowId: 'workflow_id', blockId: 'block_id', deploymentVersionId: 'deployment_version_id', + deploymentOperationId: 'deployment_operation_id', id: 'id', archivedAt: 'archived_at', }, @@ -813,7 +814,7 @@ describe('Schedule Deploy Utilities', () => { setupMockTransaction() - await createSchedulesForDeploy('workflow-1', blocks) + await createSchedulesForDeploy('workflow-1', blocks, undefined, 'version-1', 'operation-1') expect(mockOnConflictDoUpdate).toHaveBeenCalledWith({ target: expect.any(Array), @@ -821,6 +822,7 @@ describe('Schedule Deploy Utilities', () => { set: expect.objectContaining({ blockId: 'block-1', cronExpression: '0 9 * * *', + deploymentOperationId: 'operation-1', status: 'active', failedCount: 0, }), diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index 4a6d7893d9b..1f2a1bfd662 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -4,7 +4,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy' import type { BlockState } from '@/lib/workflows/schedules/utils' import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation' @@ -31,7 +30,8 @@ export async function createSchedulesForDeploy( workflowId: string, blocks: Record, tx?: DbOrTx, - deploymentVersionId?: string + deploymentVersionId?: string, + deploymentOperationId?: string ): Promise { const scheduleBlocks = findScheduleBlocks(blocks) @@ -110,6 +110,7 @@ export async function createSchedulesForDeploy( id: scheduleId, workflowId, deploymentVersionId: deploymentVersionId || null, + deploymentOperationId: deploymentOperationId || null, blockId, cronExpression, triggerType: 'schedule', @@ -126,6 +127,7 @@ export async function createSchedulesForDeploy( blockId, cronExpression, ...(deploymentVersionId ? { deploymentVersionId } : {}), + ...(deploymentOperationId ? { deploymentOperationId } : {}), updatedAt: now, nextRunAt, timezone, @@ -201,34 +203,3 @@ export async function deleteSchedulesForWorkflow( : `Deleted all schedules for workflow ${workflowId}` ) } - -async function cleanupDeploymentVersion(params: { - workflowId: string - workflow: Record - requestId: string - deploymentVersionId: string - /** - * If true, skip external subscription cleanup (already done by saveTriggerWebhooksForDeploy). - * Only deletes DB records. - */ - skipExternalCleanup?: boolean - strictExternalCleanup?: boolean -}): Promise { - const { - workflowId, - workflow, - requestId, - deploymentVersionId, - skipExternalCleanup = false, - strictExternalCleanup = false, - } = params - await cleanupWebhooksForWorkflow( - workflowId, - workflow, - requestId, - deploymentVersionId, - skipExternalCleanup, - strictExternalCleanup - ) - await deleteSchedulesForWorkflow(workflowId, db, deploymentVersionId) -} diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index cd61dad4abf..93d0c552267 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -37,8 +37,8 @@ describe('listSkills includeBuiltins', () => { expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) }) - // The mothership skill registry passes includeBuiltins: false so it never sees - // the code-only template skills (it loads workspace skills via load_user_skill). + // The mothership skill inventory passes includeBuiltins: false so it never sees + // the code-only template skills. it('excludes builtin template skills when includeBuiltins is false', async () => { orderByMock.mockResolvedValue([ { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 361ef667178..7e0fcfb4c2f 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -36,8 +36,8 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * real skills do. A workspace skill that shares a built-in's name overrides it. * * Pass `includeBuiltins: false` to return only user-created skills. The - * mothership uses this for the skill registry it sees, since it loads workspace - * skills via load_user_skill and never the code-only templates. + * mothership uses this for the workspace skill inventory it sees, which lists + * only user-created skills and never the code-only templates. */ export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { const dbRows = await db diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 4b71917e460..0d94267af50 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -29,6 +29,9 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'serviceDeskId', 'impersonateUserEmail', 'boardId', + 'spaceId', + 'listSpaceId', + 'folderId', 'awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', diff --git a/apps/sim/lib/workspace-events/no-activity.ts b/apps/sim/lib/workspace-events/no-activity.ts index a93a822a73c..8f633f94726 100644 --- a/apps/sim/lib/workspace-events/no-activity.ts +++ b/apps/sim/lib/workspace-events/no-activity.ts @@ -2,6 +2,7 @@ import { db, dbReplica } from '@sim/db' import { webhook, workflow, workflowDeploymentVersion, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, eq, gt, gte, inArray, isNull, ne, or, sql } from 'drizzle-orm' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { SIM_RULE_COOLDOWN_HOURS, SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants' import { dispatchSimEvent } from '@/lib/workspace-events/emitter' import { buildNoActivityEventPayload } from '@/lib/workspace-events/payload' @@ -56,8 +57,7 @@ async function fetchNoActivitySubscriptionPage( .where( and( eq(webhook.provider, SIM_TRIGGER_PROVIDER), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), eq(workflow.isDeployed, true), isNull(workflow.archivedAt), sql`${webhook.providerConfig}->>'eventType' = 'no_activity'`, diff --git a/apps/sim/lib/workspace-events/subscriptions.ts b/apps/sim/lib/workspace-events/subscriptions.ts index d44ef32bc19..67951b46519 100644 --- a/apps/sim/lib/workspace-events/subscriptions.ts +++ b/apps/sim/lib/workspace-events/subscriptions.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { and, eq, isNull, or } from 'drizzle-orm' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { SIM_EVENT_TYPES, SIM_RULE_DEFAULTS, @@ -34,8 +35,7 @@ export async function fetchSimTriggerSubscriptions( .where( and( eq(webhook.provider, SIM_TRIGGER_PROVIDER), - eq(webhook.isActive, true), - isNull(webhook.archivedAt), + deliverableWebhookPredicate(webhook), eq(workflow.workspaceId, workspaceId), eq(workflow.isDeployed, true), isNull(workflow.archivedAt), diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..ce11157ccb7 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -5,6 +5,7 @@ import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, FileConflictError, + moveRenameWorkspaceFile, moveWorkspaceFileItems, renameWorkspaceFile, restoreWorkspaceFile, @@ -307,6 +308,72 @@ export async function performRenameWorkspaceFile( } } +export interface PerformMoveRenameWorkspaceFileParams { + workspaceId: string + userId: string + fileId: string + targetFolderId: string | null + newName: string +} + +export interface PerformMoveRenameWorkspaceFileResult { + success: boolean + error?: string + errorCode?: WorkspaceFilesOrchestrationErrorCode + file?: WorkspaceFileRecord +} + +export async function performMoveRenameWorkspaceFile( + params: PerformMoveRenameWorkspaceFileParams +): Promise { + const { workspaceId, userId, fileId, targetFolderId, newName } = params + + try { + const { file, renamed, moved } = await moveRenameWorkspaceFile({ + workspaceId, + fileId, + targetFolderId, + newName, + }) + logger.info('Moved/renamed workspace file', { workspaceId, fileId, renamed, moved }) + + if (moved) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Moved file "${file.name}"${targetFolderId ? ' to folder' : ' to root'}`, + metadata: { targetFolderId }, + }) + } + if (renamed) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Renamed file to "${file.name}"`, + }) + } + + return { success: true, file } + } catch (error) { + logger.error('Failed to move/rename workspace file', { error }) + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + if (toError(error).message.includes('not found')) { + return { success: false, error: toError(error).message, errorCode: 'not_found' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + export async function performRestoreWorkspaceFile( params: PerformRestoreWorkspaceFileParams ): Promise { diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 5e5268bda8c..81c7af23352 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -3,6 +3,8 @@ export { type PerformCreateWorkspaceFileFolderResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformMoveRenameWorkspaceFileParams, + type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, type PerformMoveWorkspaceFileItemsResult, type PerformRenameWorkspaceFileParams, @@ -15,6 +17,7 @@ export { type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, performDeleteWorkspaceFileItems, + performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, performRenameWorkspaceFile, performRestoreWorkspaceFile, diff --git a/apps/sim/lib/workspaces/list.ts b/apps/sim/lib/workspaces/list.ts new file mode 100644 index 00000000000..0b2fd146fba --- /dev/null +++ b/apps/sim/lib/workspaces/list.ts @@ -0,0 +1,128 @@ +import { db } from '@sim/db' +import { settings, type workspace as workspaceTable } from '@sim/db/schema' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { eq } from 'drizzle-orm' +import type { PlanCategory } from '@/lib/billing/plan-helpers' +import { + evaluateWorkspaceInvitePolicy, + getInvitePlanCategoryForOrganization, + getInvitePlanCategoryForUser, + getWorkspaceCreationPolicy, + resolveInviteFlags, + WORKSPACE_MODE, + type WorkspaceCreationPolicy, + type WorkspaceInviteFlags, +} from '@/lib/workspaces/policy' +import { listAccessibleWorkspaceRowsForUser, type WorkspaceScope } from '@/lib/workspaces/utils' + +type WorkspaceRow = typeof workspaceTable.$inferSelect + +/** Accessible workspace row decorated with the viewer's role and invite policy flags. */ +export type WorkspaceWithInviteFlags = WorkspaceRow & + WorkspaceInviteFlags & { + role: 'owner' | 'admin' | 'member' + permissions: PermissionType + } + +/** The GET /api/workspaces payload assembled by {@link listWorkspacesForViewer}. */ +export interface WorkspaceListPayload { + workspaces: WorkspaceWithInviteFlags[] + lastActiveWorkspaceId: string | null + creationPolicy: WorkspaceCreationPolicy +} + +/** + * Decorates accessible workspace rows with the viewer's role and per-workspace + * invite policy flags (resolving each workspace's billed plan category once per + * billed user / organization). + */ +async function buildWorkspacesWithInviteFlags( + userWorkspaces: Array<{ workspace: WorkspaceRow; permissionType: PermissionType }>, + userId: string +): Promise { + const nonOrgBilledUserIds = [ + ...new Set( + userWorkspaces + .filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION) + .map(({ workspace: ws }) => ws.billedAccountUserId) + ), + ] + const orgIds = [ + ...new Set( + userWorkspaces + .filter( + ({ workspace: ws }) => + ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId + ) + .map(({ workspace: ws }) => ws.organizationId as string) + ), + ] + const planCategoryByBilledUser = new Map() + const planCategoryByOrg = new Map() + await Promise.all([ + ...nonOrgBilledUserIds.map(async (billedUserId) => { + planCategoryByBilledUser.set(billedUserId, await getInvitePlanCategoryForUser(billedUserId)) + }), + ...orgIds.map(async (orgId) => { + planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId)) + }), + ]) + + return userWorkspaces.map(({ workspace: workspaceDetails, permissionType }) => { + const billedPlanCategory: PlanCategory = + workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION + ? workspaceDetails.organizationId + ? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free') + : 'free' + : (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free') + const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory }) + + return { + ...workspaceDetails, + role: + workspaceDetails.ownerId === userId + ? ('owner' as const) + : permissionType === 'admin' + ? ('admin' as const) + : ('member' as const), + permissions: permissionType, + ...resolveInviteFlags(invitePolicy, workspaceDetails.billedAccountUserId === userId), + } + }) +} + +/** + * Read-only assembly of the GET /api/workspaces payload for a viewer: accessible + * workspaces with role/invite flags, the viewer's last active workspace id, and + * the workspace creation policy. + * + * Unlike the route, this performs no writes — no default-workspace creation and + * no orphaned-workflow repair. It exists for the workspace layout's sidebar + * prefetch, which only runs after host-context authorization has proven the + * viewer already has at least one accessible workspace. + */ +export async function listWorkspacesForViewer(params: { + userId: string + activeOrganizationId: string | null + scope?: WorkspaceScope +}): Promise { + const { userId, activeOrganizationId, scope = 'active' } = params + + const [creationPolicy, workspaces, userSettings] = await Promise.all([ + getWorkspaceCreationPolicy({ userId, activeOrganizationId }), + listAccessibleWorkspaceRowsForUser(userId, scope).then((rows) => + buildWorkspacesWithInviteFlags(rows, userId) + ), + db + .select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1), + ]) + + return { + workspaces, + lastActiveWorkspaceId: userSettings[0]?.lastActiveWorkspaceId ?? null, + creationPolicy, + } +} diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index c44505bbd82..428610ac014 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAuditBatch } from '@sim/audit' import { db } from '@sim/db' import { member, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -410,6 +411,32 @@ export async function detachOrganizationWorkspaces( return [...workspaceIds].sort() }) + const workspacesById = new Map( + organizationWorkspaces.map((organizationWorkspace) => [ + organizationWorkspace.id, + organizationWorkspace, + ]) + ) + recordAuditBatch( + detachedWorkspaceIds.map((detachedWorkspaceId) => { + const detachedWorkspace = workspacesById.get(detachedWorkspaceId) + return { + workspaceId: detachedWorkspaceId, + actorId: null, + actorName: 'Billing System', + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: detachedWorkspaceId, + description: 'Workspace detached from organization after its subscription ended', + metadata: { + organizationId, + previousBilledAccountUserId: detachedWorkspace?.billedAccountUserId ?? null, + newBilledAccountUserId: organizationOwnerId ?? detachedWorkspace?.ownerId ?? null, + }, + } + }) + ) + logger.info('Detached organization workspaces', { organizationId, detachedWorkspaceCount: detachedWorkspaceIds.length, diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index b6ec9c3ca13..5ad280e28b5 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -10,7 +10,10 @@ import type { PlanCategory } from '@/lib/billing/plan-helpers' import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' +import { + CONTACT_OWNER_TO_UPGRADE_REASON, + UPGRADE_TO_INVITE_REASON, +} from '@/lib/workspaces/policy-constants' const logger = createLogger('WorkspacePolicy') @@ -40,6 +43,33 @@ export interface WorkspaceInvitePolicy { upgradeRequired: boolean } +/** Caller-facing invite flags derived from an evaluated invite policy. */ +export interface WorkspaceInviteFlags { + inviteMembersEnabled: boolean + inviteDisabledReason: string | null + inviteUpgradeRequired: boolean +} + +/** + * Derives the caller-facing invite flags for a workspace response. Only the + * billed user can act on an upgrade, so everyone else gets the contact-owner + * message when invites are disabled. + */ +export function resolveInviteFlags( + invitePolicy: WorkspaceInvitePolicy, + callerIsBilledUser: boolean +): WorkspaceInviteFlags { + return { + inviteMembersEnabled: invitePolicy.allowed, + inviteDisabledReason: invitePolicy.allowed + ? null + : callerIsBilledUser + ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) + : CONTACT_OWNER_TO_UPGRADE_REASON, + inviteUpgradeRequired: invitePolicy.upgradeRequired && callerIsBilledUser, + } +} + export interface WorkspaceCreationPolicy { canCreate: boolean workspaceMode: WorkspaceMode diff --git a/apps/sim/package.json b/apps/sim/package.json index 398d3d03e04..a7800a9f904 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -67,6 +67,7 @@ "@e2b/code-interpreter": "^2.0.0", "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", + "@google-cloud/storage": "7.21.0", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", "@linear/sdk": "40.0.0", diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index c902d959382..71ead60cd0a 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -39,6 +39,7 @@ export type AttachmentProvider = | 'nvidia' | 'meta' | 'zai' + | 'kimi' export interface PreparedProviderAttachment { file: UserFile @@ -152,6 +153,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { nvidia: 'no file attachments in the current API adapter', meta: 'no file attachments in the current API adapter', zai: 'no file attachments in the current API adapter', + kimi: 'images through image_url message parts on multimodal models', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -175,6 +177,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'nvidia') return 'nvidia' if (providerId === 'meta') return 'meta' if (providerId === 'zai') return 'zai' + if (providerId === 'kimi') return 'kimi' return null } @@ -319,6 +322,7 @@ function isMimeTypeSupportedByProvider( case 'vllm': case 'litellm': case 'xai': + case 'kimi': return isImageMimeType(mimeType) case 'deepseek': case 'cerebras': diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts new file mode 100644 index 00000000000..0d02c00710a --- /dev/null +++ b/apps/sim/providers/kimi/index.ts @@ -0,0 +1,632 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { createReadableStreamFromKimiStream } from '@/providers/kimi/utils' +import { + getModelCapabilities, + getProviderDefaultModel, + getProviderModels, +} from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + enforceStrictSchema, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('KimiProvider') + +const KIMI_BASE_URL = 'https://api.moonshot.ai/v1' + +/** Kimi models whose thinking mode can be toggled off; the rest always reason. */ +const THINKING_TOGGLE_MODELS = new Set( + getProviderModels('kimi').filter((id) => + getModelCapabilities(id)?.thinking?.levels.includes('disabled') + ) +) + +function buildResponseFormatPayload( + responseFormat: NonNullable +) { + const isStrict = responseFormat.strict !== false + const rawSchema = responseFormat.schema || responseFormat + return { + type: 'json_schema' as const, + json_schema: { + name: responseFormat.name || 'response_schema', + schema: isStrict ? enforceStrictSchema(rawSchema) : rawSchema, + strict: isStrict, + }, + } +} + +/** + * Moonshot AI's Kimi models via an OpenAI-compatible chat-completions API (`api.moonshot.ai`), + * with these documented model-family constraints baked into the adapter: + * - Every current Kimi model pins `temperature`/`top_p` server-side (passing another value is + * rejected), so the adapter never sends `temperature` and no model declares the capability. + * - Output length is capped via `max_completion_tokens` (Kimi's documented parameter). + * - `thinking: { type }` maps from `request.thinkingLevel` on the models whose definition + * declares the toggle (currently kimi-k2.6); always-reasoning models (kimi-k3, + * kimi-k2.7-code) take no toggle, so the parameter is never sent for them. + * - `response_format: json_schema` structured output is supported natively (`name`/`strict`/ + * `schema` nesting per Kimi's API reference). + * - `tool_choice` supports `"auto"` and the `{ type: "function" }` object form, but the API + * rejects the object form whenever thinking is enabled ("tool_choice 'specified' is + * incompatible with thinking enabled", verified live). On models with a thinking toggle the + * adapter therefore sends `thinking: { type: "disabled" }` for the duration of a forced-tool + * request; on always-thinking models (kimi-k3, kimi-k2.7-code) it downgrades the forced + * choice to `"auto"` with a warning, mirroring the Z.ai adapter's behavior. + */ +export const kimiProvider: ProviderConfig = { + id: 'kimi', + name: 'Kimi', + description: "Moonshot AI's Kimi models via an OpenAI-compatible API", + version: '1.0.0', + models: getProviderModels('kimi'), + defaultModel: getProviderDefaultModel('kimi'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Kimi') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const kimi = new OpenAI({ + apiKey: request.apiKey, + baseURL: KIMI_BASE_URL, + }) + + const allMessages = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'kimi') + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: any = { + model: request.model, + messages: formattedMessages, + } + + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + if ( + THINKING_TOGGLE_MODELS.has(request.model) && + (request.thinkingLevel === 'enabled' || request.thinkingLevel === 'disabled') + ) { + payload.thinking = { type: request.thinkingLevel } + } + + if (request.responseFormat) { + payload.response_format = buildResponseFormatPayload(request.responseFormat) + } + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice + hasActiveTools = true + + if (typeof toolChoice === 'object') { + if (THINKING_TOGGLE_MODELS.has(request.model)) { + if (payload.thinking?.type === 'enabled') { + logger.warn( + 'Kimi rejects forced tool_choice while thinking is enabled — disabling thinking for this forced-tool request', + { model: request.model } + ) + } + payload.thinking = { type: 'disabled' } + } else { + logger.warn( + 'Kimi rejects forced tool_choice on always-thinking models — ignoring force setting and falling back to auto', + { forcedTools: preparedTools.forcedTools, model: request.model } + ) + payload.tool_choice = 'auto' + } + } + + logger.info('Kimi request configuration:', { + toolCount: filteredTools.length, + toolChoice: + typeof payload.tool_choice === 'string' + ? payload.tool_choice + : `force:${payload.tool_choice.function?.name}`, + model: request.model, + }) + } + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for Kimi request (no tools)') + + const streamResponse = await kimi.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await kimi.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + if ( + typeof originalToolChoice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'kimi' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + const assistantReasoning = ( + currentResponse.choices[0]?.message as { reasoning_content?: string } | undefined + )?.reasoning_content + + currentMessages.push({ + role: 'assistant', + content: null, + ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + currentResponse = await kimi.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + if ( + typeof nextPayload.tool_choice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + nextPayload.tool_choice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'kimi' } + ) + } + } catch (error) { + logger.error('Error in Kimi request:', { error }) + throw error + } + + if (request.stream) { + logger.info('Using streaming for final Kimi response after tool processing') + + const streamingPayload: any = { + ...payload, + messages: currentMessages, + stream: true, + stream_options: { include_usage: true }, + } + streamingPayload.tools = undefined + streamingPayload.tool_choice = undefined + + const streamResponse = await kimi.chat.completions.create( + streamingPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) + + return streamingResult + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in Kimi request:', { + error, + duration: totalDuration, + }) + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/kimi/utils.ts b/apps/sim/providers/kimi/utils.ts new file mode 100644 index 00000000000..c52a0cd50ad --- /dev/null +++ b/apps/sim/providers/kimi/utils.ts @@ -0,0 +1,10 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +export function createReadableStreamFromKimiStream( + kimiStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(kimiStream, 'Kimi', onComplete) +} diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 14dcee48590..630da93fb2e 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -219,6 +219,68 @@ describe('zai provider definition', () => { }) }) +describe('kimi provider definition', () => { + const kimi = PROVIDER_DEFINITIONS.kimi + + const expectedModels = [ + { id: 'kimi-k3', contextWindow: 1048576 }, + { id: 'kimi-k2.7-code', contextWindow: 262144 }, + { id: 'kimi-k2.7-code-highspeed', contextWindow: 262144 }, + { id: 'kimi-k2.6', contextWindow: 262144 }, + ] + + it('is registered with kimi-k2.6 as the default model', () => { + expect(kimi).toBeDefined() + expect(kimi.id).toBe('kimi') + // kimi-k2.6 (not the flagship kimi-k3) — k3 access is tier-gated on Moonshot accounts, + // and the default must be a model every account can serve. + expect(kimi.defaultModel).toBe('kimi-k2.6') + // No fallback pattern — an unscoped `/^kimi/` would overmatch Kimi weights re-hosted by + // other providers and misroute them to Moonshot's hosted billing. + expect(kimi.modelPatterns).toEqual([]) + }) + + it('exposes every Kimi model with the documented context window', () => { + expect(kimi.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id)) + for (const expected of expectedModels) { + const model = kimi.models.find((m) => m.id === expected.id) + expect(model?.contextWindow).toBe(expected.contextWindow) + } + }) + + it('declares no temperature capability since every current Kimi model pins it server-side', () => { + expect(kimi.capabilities?.temperature).toBeUndefined() + for (const model of kimi.models) { + expect(model.capabilities.temperature).toBeUndefined() + } + }) + + it('exposes the thinking toggle only on kimi-k2.6', () => { + for (const model of kimi.models) { + const hasToggle = model.id === 'kimi-k2.6' + if (hasToggle) { + expect(model.capabilities.thinking).toEqual({ + levels: ['disabled', 'enabled'], + default: 'enabled', + }) + } else { + expect(model.capabilities.thinking).toBeUndefined() + } + } + }) + + it('routes every kimi model ID to the kimi provider', () => { + const baseModels = getBaseModelProviders() + for (const expected of expectedModels) { + expect(baseModels[expected.id]).toBe('kimi') + } + }) + + it('is included in getHostedModels since Sim provides the Kimi key server-side', () => { + expect(getHostedModels()).toContain('kimi-k3') + }) +}) + describe('xai provider definition', () => { const xai = PROVIDER_DEFINITIONS.xai diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index bc5aa24cbe7..ae5e201c8e2 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -18,6 +18,7 @@ import { FireworksIcon, GeminiIcon, GroqIcon, + KimiIcon, LitellmIcon, MetaIcon, MistralIcon, @@ -2548,6 +2549,85 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + kimi: { + id: 'kimi', + name: 'Kimi', + description: "Moonshot AI's Kimi models via an OpenAI-compatible API", + defaultModel: 'kimi-k2.6', + // No fallback pattern — an unscoped `/^kimi/` would overmatch Kimi weights re-hosted by + // other providers (e.g. `moonshotai/kimi-*` on aggregators) and misroute them here. + modelPatterns: [], + icon: KimiIcon, + color: '#1783FF', + contextInformationAvailable: true, + capabilities: { + toolUsageControl: true, + }, + models: [ + { + id: 'kimi-k3', + pricing: { + input: 3.0, + cachedInput: 0.3, + output: 15.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + maxOutputTokens: 1048576, + }, + contextWindow: 1048576, + releaseDate: '2026-07-16', + recommended: true, + }, + { + id: 'kimi-k2.7-code', + pricing: { + input: 0.95, + cachedInput: 0.19, + output: 4.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + }, + contextWindow: 262144, + releaseDate: '2026-06-12', + }, + { + id: 'kimi-k2.7-code-highspeed', + pricing: { + input: 1.9, + cachedInput: 0.38, + output: 8.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + }, + contextWindow: 262144, + releaseDate: '2026-06-12', + speedOptimized: true, + }, + { + id: 'kimi-k2.6', + pricing: { + input: 0.95, + cachedInput: 0.16, + output: 4.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 262144, + }, + ], + }, zai: { id: 'zai', name: 'Z.ai', @@ -3922,6 +4002,7 @@ export function getHostedModels(): string[] { ...getProviderModels('google'), ...getProviderModels('zai'), ...getProviderModels('xai'), + ...getProviderModels('kimi'), ] } diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 9e98d02d134..529d7c0b09f 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -10,6 +10,7 @@ import { deepseekProvider } from '@/providers/deepseek' import { fireworksProvider } from '@/providers/fireworks' import { googleProvider } from '@/providers/google' import { groqProvider } from '@/providers/groq' +import { kimiProvider } from '@/providers/kimi' import { litellmProvider } from '@/providers/litellm' import { metaProvider } from '@/providers/meta' import { mistralProvider } from '@/providers/mistral' @@ -42,6 +43,7 @@ const providerRegistry: Record = { nvidia: nvidiaProvider, meta: metaProvider, zai: zaiProvider, + kimi: kimiProvider, vllm: vllmProvider, litellm: litellmProvider, mistral: mistralProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 289957a5296..c02dc2ebd08 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -16,6 +16,7 @@ export type ProviderId = | 'nvidia' | 'meta' | 'zai' + | 'kimi' | 'mistral' | 'ollama' | 'ollama-cloud' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1c6904c5c30..641423746e0 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -159,6 +159,7 @@ export const providers: Record = { nvidia: buildProviderMetadata('nvidia'), meta: buildProviderMetadata('meta'), zai: buildProviderMetadata('zai'), + kimi: buildProviderMetadata('kimi'), mistral: buildProviderMetadata('mistral'), bedrock: buildProviderMetadata('bedrock'), openrouter: buildProviderMetadata('openrouter'), @@ -905,8 +906,12 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isGeminiModel = provider === 'google' const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' + const isKimiModel = provider === 'kimi' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel)) { + if ( + isHosted && + (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel || isKimiModel) + ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg b/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg new file mode 100644 index 00000000000..ef79efb8832 Binary files /dev/null and b/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg differ diff --git a/apps/sim/scripts/check-block-registry.ts b/apps/sim/scripts/check-block-registry.ts index 13089512f14..6a8421e41a7 100644 --- a/apps/sim/scripts/check-block-registry.ts +++ b/apps/sim/scripts/check-block-registry.ts @@ -128,7 +128,9 @@ function getPreviousIds(): PreviousIdsResult { continue } - const typeMatch = content.match(/BlockConfig\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/) + const typeMatch = content.match( + /BlockConfig(?:<[^>]*>)?\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/ + ) if (!typeMatch) continue const blockType = typeMatch[1] diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 43192146ccd..d26dd609e9e 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -36,3 +36,4 @@ export type ChatContext = | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } + | { kind: 'mcp'; serverId: string; label: string } diff --git a/apps/sim/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts index 77e4fd8a02c..4392fab735f 100644 --- a/apps/sim/stores/workflows/registry/store.test.ts +++ b/apps/sim/stores/workflows/registry/store.test.ts @@ -152,6 +152,43 @@ describe('registry store loadWorkflowState (collapsed cache)', () => { expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready') }) + it('preserves the cached in-flight deployment attempt across envelope hydration', async () => { + const client = sharedQueryClient.current as QueryClient + const preparingAttempt = { + id: 'op-1', + deploymentVersionId: 'dv-1', + version: 2, + action: 'deploy', + status: 'preparing', + readiness: { webhooks: 'pending', schedules: 'pending', mcp: 'pending' }, + requestedAt: '2026-07-14T00:00:00.000Z', + activatedAt: null, + error: null, + } + client.setQueryData(['deployments', 'info', 'wf-1'], { + isDeployed: false, + deployedAt: null, + apiKey: 'Workspace API keys', + needsRedeployment: true, + isPublicApi: false, + warnings: ['Deployment preparation is queued'], + activeDeployment: null, + latestDeploymentAttempt: preparingAttempt, + }) + mockRequestJson.mockResolvedValue({ data: makeEnvelope({ isDeployed: false }) }) + + await useWorkflowRegistry.getState().loadWorkflowState('wf-1') + + const deploymentInfo = client.getQueryData(['deployments', 'info', 'wf-1']) + expect(deploymentInfo).toMatchObject({ + isDeployed: false, + apiKey: 'Workspace API keys', + needsRedeployment: true, + warnings: ['Deployment preparation is queued'], + latestDeploymentAttempt: { id: 'op-1', status: 'preparing' }, + }) + }) + it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => { const envelope = makeEnvelope() mockRequestJson.mockResolvedValue({ data: envelope }) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 97a0b556728..083b116d848 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -114,6 +114,13 @@ export const useWorkflowRegistry = create()( apiKey: prev?.apiKey ?? null, needsRedeployment: prev?.needsRedeployment ?? false, isPublicApi: workflowData.isPublicApi, + /** + * Envelope hydration has no lifecycle data; keep the cached + * attempt so in-flight status polling is not interrupted. + */ + warnings: prev?.warnings, + activeDeployment: prev?.activeDeployment ?? null, + latestDeploymentAttempt: prev?.latestDeploymentAttempt ?? null, }) ) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 1cd8ba6baba..a4428aaf5f3 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -21,6 +21,8 @@ export interface WorkflowMetadata { sortOrder: number archivedAt?: Date | null locked?: boolean + forkSyncExcluded?: boolean + isDeployed?: boolean } export type HydrationPhase = 'idle' | 'creating' | 'state-loading' | 'ready' | 'error' diff --git a/apps/sim/tools/airtable/airtable.test.ts b/apps/sim/tools/airtable/airtable.test.ts new file mode 100644 index 00000000000..3fd800f8c76 --- /dev/null +++ b/apps/sim/tools/airtable/airtable.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { airtableCreateRecordsTool } from '@/tools/airtable/create_records' +import { airtableUpdateMultipleRecordsTool } from '@/tools/airtable/update_multiple_records' +import { airtableUpdateRecordTool } from '@/tools/airtable/update_record' +import { airtableUpsertRecordsTool } from '@/tools/airtable/upsert_records' + +const nestedFields = { + StringBoolean: 'false', + StringNumber: '42', + Boolean: true, + Number: 42, + Nested: { + values: ['true', false, '001'], + }, +} + +const baseParams = { + accessToken: 'token', + baseId: 'app123', + tableId: 'tbl123', +} + +const requestCases = [ + { + operation: 'create records', + buildBody: (typecast?: boolean) => + airtableCreateRecordsTool.request.body!({ + ...baseParams, + records: [{ fields: nestedFields }], + typecast, + }), + body: { + records: [{ fields: nestedFields }], + }, + }, + { + operation: 'update record', + buildBody: (typecast?: boolean) => + airtableUpdateRecordTool.request.body!({ + ...baseParams, + recordId: 'rec123', + fields: nestedFields, + typecast, + }), + body: { + fields: nestedFields, + }, + }, + { + operation: 'update multiple records', + buildBody: (typecast?: boolean) => + airtableUpdateMultipleRecordsTool.request.body!({ + ...baseParams, + records: [{ id: 'rec123', fields: nestedFields }], + typecast, + }), + body: { + records: [{ id: 'rec123', fields: nestedFields }], + }, + }, + { + operation: 'upsert records', + buildBody: (typecast?: boolean) => + airtableUpsertRecordsTool.request.body!({ + ...baseParams, + records: [{ fields: nestedFields }], + fieldsToMergeOn: ['External ID'], + typecast, + }), + body: { + performUpsert: { fieldsToMergeOn: ['External ID'] }, + records: [{ fields: nestedFields }], + }, + }, +] as const + +describe.each(requestCases)('Airtable $operation request body', ({ buildBody, body }) => { + it('omits typecast when unset and preserves nested values', () => { + expect(buildBody()).toEqual(body) + }) + + it('includes typecast true at the request-body root', () => { + expect(buildBody(true)).toEqual({ ...body, typecast: true }) + }) + + it('includes typecast false at the request-body root', () => { + expect(buildBody(false)).toEqual({ ...body, typecast: false }) + }) +}) diff --git a/apps/sim/tools/airtable/create_records.ts b/apps/sim/tools/airtable/create_records.ts index cbb4b305351..e6273b845df 100644 --- a/apps/sim/tools/airtable/create_records.ts +++ b/apps/sim/tools/airtable/create_records.ts @@ -38,6 +38,12 @@ export const airtableCreateRecordsTool: ToolConfig ({ records: params.records }), + body: (params) => ({ + records: params.records, + ...(params.typecast != null ? { typecast: params.typecast } : {}), + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/airtable/types.ts b/apps/sim/tools/airtable/types.ts index 499b4c898ed..10246ed1292 100644 --- a/apps/sim/tools/airtable/types.ts +++ b/apps/sim/tools/airtable/types.ts @@ -1,3 +1,4 @@ +import type { AirtableGetBaseSchemaResponse } from '@/tools/airtable/get_base_schema' import type { ToolResponse } from '@/tools/types' // Common types @@ -42,18 +43,16 @@ export interface AirtableTable { fields: AirtableField[] } -interface AirtableView { - id: string - name: string - type: string -} - interface AirtableBaseParams { accessToken: string baseId: string tableId: string } +interface AirtableTypecastParams { + typecast?: boolean +} + // List Bases Types export interface AirtableListBasesParams { accessToken: string @@ -117,7 +116,7 @@ export interface AirtableGetResponse extends ToolResponse { } // Create Records Types -export interface AirtableCreateParams extends AirtableBaseParams { +export interface AirtableCreateParams extends AirtableBaseParams, AirtableTypecastParams { records: Array<{ fields: Record }> } @@ -131,7 +130,7 @@ export interface AirtableCreateResponse extends ToolResponse { } // Update Record Types (Single) -export interface AirtableUpdateParams extends AirtableBaseParams { +export interface AirtableUpdateParams extends AirtableBaseParams, AirtableTypecastParams { recordId: string fields: Record } @@ -147,7 +146,7 @@ export interface AirtableUpdateResponse extends ToolResponse { } // Update Multiple Records Types -export interface AirtableUpdateMultipleParams extends AirtableBaseParams { +export interface AirtableUpdateMultipleParams extends AirtableBaseParams, AirtableTypecastParams { records: Array<{ id: string; fields: Record }> } @@ -182,10 +181,9 @@ export interface AirtableDeleteResponse extends ToolResponse { } // Upsert Records Types -export interface AirtableUpsertParams extends AirtableBaseParams { +export interface AirtableUpsertParams extends AirtableBaseParams, AirtableTypecastParams { records: Array<{ fields: Record }> fieldsToMergeOn: string[] - typecast?: boolean } export interface AirtableUpsertResponse extends ToolResponse { @@ -211,18 +209,4 @@ export type AirtableResponse = | AirtableUpdateMultipleResponse | AirtableDeleteResponse | AirtableUpsertResponse - | AirtableListBasesResponse | AirtableGetBaseSchemaResponse - -interface AirtableGetBaseSchemaResponse extends ToolResponse { - output: { - tables: Array<{ - id: string - name: string - description?: string - fields: Array<{ id: string; name: string; type: string; description?: string }> - views: Array<{ id: string; name: string; type: string }> - }> - metadata: { totalTables: number } - } -} diff --git a/apps/sim/tools/airtable/update_multiple_records.ts b/apps/sim/tools/airtable/update_multiple_records.ts index f7f244a0b97..d446db94bfc 100644 --- a/apps/sim/tools/airtable/update_multiple_records.ts +++ b/apps/sim/tools/airtable/update_multiple_records.ts @@ -43,6 +43,12 @@ export const airtableUpdateMultipleRecordsTool: ToolConfig< visibility: 'user-or-llm', description: 'Array of records to update, each with an `id` and a `fields` object', }, + typecast: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'When true, Airtable automatically converts string values to the field type', + }, }, request: { @@ -53,7 +59,10 @@ export const airtableUpdateMultipleRecordsTool: ToolConfig< Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', }), - body: (params) => ({ records: params.records }), + body: (params) => ({ + records: params.records, + ...(params.typecast != null ? { typecast: params.typecast } : {}), + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/airtable/update_record.ts b/apps/sim/tools/airtable/update_record.ts index b3d37510663..fd3d105d21b 100644 --- a/apps/sim/tools/airtable/update_record.ts +++ b/apps/sim/tools/airtable/update_record.ts @@ -43,6 +43,12 @@ export const airtableUpdateRecordTool: ToolConfig ({ fields: params.fields }), + body: (params) => ({ + fields: params.fields, + ...(params.typecast != null ? { typecast: params.typecast } : {}), + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/clickup/add_tag_to_task.ts b/apps/sim/tools/clickup/add_tag_to_task.ts new file mode 100644 index 00000000000..47d9abed295 --- /dev/null +++ b/apps/sim/tools/clickup/add_tag_to_task.ts @@ -0,0 +1,68 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpTaskTagParams, ClickUpTaskTagResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupAddTagToTaskTool: ToolConfig = { + id: 'clickup_add_tag_to_task', + name: 'ClickUp Add Tag To Task', + description: 'Add an existing space tag to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to tag', + }, + tagName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the tag to add (must exist in the space)', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/tag/${encodeURIComponent(params.tagName)}`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to add tag to task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, tagName: params?.tagName }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the tagged task', optional: true }, + tagName: { type: 'string', description: 'Name of the tag that was added', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/create_checklist.ts b/apps/sim/tools/clickup/create_checklist.ts new file mode 100644 index 00000000000..08983d20615 --- /dev/null +++ b/apps/sim/tools/clickup/create_checklist.ts @@ -0,0 +1,79 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { ClickUpChecklistResponse, ClickUpCreateChecklistParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateChecklistTool: ToolConfig< + ClickUpCreateChecklistParams, + ClickUpChecklistResponse +> = { + id: 'clickup_create_checklist', + name: 'ClickUp Create Checklist', + description: 'Add a new checklist to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to add the checklist to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/checklist`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ name: params.name }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The created checklist', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_checklist_item.ts b/apps/sim/tools/clickup/create_checklist_item.ts new file mode 100644 index 00000000000..8476923dc9e --- /dev/null +++ b/apps/sim/tools/clickup/create_checklist_item.ts @@ -0,0 +1,97 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { + ClickUpChecklistResponse, + ClickUpCreateChecklistItemParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateChecklistItemTool: ToolConfig< + ClickUpCreateChecklistItemParams, + ClickUpChecklistResponse +> = { + id: 'clickup_create_checklist_item', + name: 'ClickUp Create Checklist Item', + description: 'Add an item to a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to add the item to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist item', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the item to', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The updated checklist including its items', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_comment.ts b/apps/sim/tools/clickup/create_comment.ts new file mode 100644 index 00000000000..ffb8742e9c7 --- /dev/null +++ b/apps/sim/tools/clickup/create_comment.ts @@ -0,0 +1,118 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCreateCommentParams, + ClickUpCreateCommentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateCommentTool: ToolConfig< + ClickUpCreateCommentParams, + ClickUpCreateCommentResponse +> = { + id: 'clickup_create_comment', + name: 'ClickUp Create Comment', + description: 'Add a comment to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to comment on', + }, + commentText: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content of the comment', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the comment to', + }, + notifyAll: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, comment notifications are sent to everyone, including the comment creator', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/comment`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + comment_text: params.commentText, + notify_all: params.notifyAll ?? false, + } + + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create comment') + return { success: false, output: { error }, error } + } + + const record = isRecordLike(data) ? data : {} + const id = record.id + const histId = record.hist_id + const rawDate = record.date + const date = + typeof rawDate === 'number' + ? rawDate + : typeof rawDate === 'string' && Number.isFinite(Number(rawDate)) + ? Number(rawDate) + : undefined + + return { + success: true, + output: { + id: id === undefined || id === null ? undefined : String(id), + histId: histId === undefined || histId === null ? undefined : String(histId), + date, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the created comment', optional: true }, + histId: { type: 'string', description: 'History ID of the created comment', optional: true }, + date: { + type: 'number', + description: 'Creation timestamp of the comment (Unix ms)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_folder.ts b/apps/sim/tools/clickup/create_folder.ts new file mode 100644 index 00000000000..e8d6c53b474 --- /dev/null +++ b/apps/sim/tools/clickup/create_folder.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_FOLDER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpFolder, +} from '@/tools/clickup/shared' +import type { ClickUpCreateFolderParams, ClickUpFolderResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateFolderTool: ToolConfig = + { + id: 'clickup_create_folder', + name: 'ClickUp Create Folder', + description: 'Create a new folder in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to create the folder in', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the folder', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/folder`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ name: params.name }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create folder') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { folder: mapClickUpFolder(data) }, + } + }, + + outputs: { + folder: { + type: 'json', + description: 'The created folder', + optional: true, + properties: CLICKUP_FOLDER_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/clickup/create_list.ts b/apps/sim/tools/clickup/create_list.ts new file mode 100644 index 00000000000..5dd011348a6 --- /dev/null +++ b/apps/sim/tools/clickup/create_list.ts @@ -0,0 +1,115 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_LIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpList, +} from '@/tools/clickup/shared' +import type { ClickUpCreateListParams, ClickUpListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateListTool: ToolConfig = { + id: 'clickup_create_list', + name: 'ClickUp Create List', + description: + 'Create a new list in a ClickUp folder, or a folderless list in a space when a space ID is provided instead', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the folder to create the list in (provide this or spaceId; folderId takes precedence when both are set)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the space to create a folderless list in (provide this or folderId)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the list', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Plain text description of the list', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Markdown description of the list (use instead of content)', + }, + }, + + request: { + url: (params) => { + if (params.folderId) { + return `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(params.folderId)}/list` + } + if (params.spaceId) { + return `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/list` + } + throw new Error('Either a folder ID or a space ID is required to create a list') + }, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.markdownContent) { + body.markdown_content = params.markdownContent + } else if (params.content) { + body.content = params.content + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create list') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { list: mapClickUpList(data) }, + } + }, + + outputs: { + list: { + type: 'json', + description: 'The created list', + optional: true, + properties: CLICKUP_LIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_task.ts b/apps/sim/tools/clickup/create_task.ts new file mode 100644 index 00000000000..9402e6f7de5 --- /dev/null +++ b/apps/sim/tools/clickup/create_task.ts @@ -0,0 +1,189 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpCreateTaskParams, ClickUpTaskResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateTaskTool: ToolConfig = { + id: 'clickup_create_task', + name: 'ClickUp Create Task', + description: 'Create a new task in a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to create the task in', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the task', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Plain text description of the task', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Markdown description of the task (overrides description)', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Status to create the task with (must exist in the list)', + }, + priority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority: 1 (urgent), 2 (high), 3 (normal), 4 (low)', + }, + dueDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Due date as a Unix timestamp in milliseconds', + }, + dueDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the due date includes a time of day', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start date as a Unix timestamp in milliseconds', + }, + startDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the start date includes a time of day', + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to assign to the task', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Tag names to apply to the task', + items: { + type: 'string', + description: 'A tag name', + }, + }, + timeEstimate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Time estimate in milliseconds', + }, + points: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Sprint points for the task', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Parent task ID to create this task as a subtask', + }, + notifyAll: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, creation notifications are sent to everyone, including the task creator', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/task`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + name: params.name, + } + + if (params.description) body.description = params.description + if (params.markdownContent) body.markdown_content = params.markdownContent + if (params.status) body.status = params.status + if (params.priority !== undefined) body.priority = params.priority + if (params.dueDate !== undefined) body.due_date = params.dueDate + if (params.dueDateTime !== undefined) body.due_date_time = params.dueDateTime + if (params.startDate !== undefined) body.start_date = params.startDate + if (params.startDateTime !== undefined) body.start_date_time = params.startDateTime + if (params.assignees?.length) body.assignees = params.assignees + if (params.tags?.length) body.tags = params.tags + if (params.timeEstimate !== undefined) body.time_estimate = params.timeEstimate + if (params.points !== undefined) body.points = params.points + if (params.parent) body.parent = params.parent + if (params.notifyAll !== undefined) body.notify_all = params.notifyAll + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The created task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/create_time_entry.ts b/apps/sim/tools/clickup/create_time_entry.ts new file mode 100644 index 00000000000..e31cb3de425 --- /dev/null +++ b/apps/sim/tools/clickup/create_time_entry.ts @@ -0,0 +1,129 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpCreateTimeEntryParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupCreateTimeEntryTool: ToolConfig< + ClickUpCreateTimeEntryParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_create_time_entry', + name: 'ClickUp Create Time Entry', + description: 'Create a manual time entry in a ClickUp workspace, optionally linked to a task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to create the entry in', + }, + start: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Start of the entry as a Unix timestamp in milliseconds', + }, + duration: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Duration of the entry in milliseconds', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the time entry', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the entry with', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to create the entry for (workspace owners/admins only)', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + start: params.start, + duration: params.duration, + } + + if (params.description) body.description = params.description + if (params.billable !== undefined) body.billable = params.billable + if (params.taskId) body.tid = params.taskId + if (params.assignee !== undefined) body.assignee = params.assignee + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to create time entry') + return { success: false, output: { error }, error } + } + + const entry = + isRecordLike(data) && isRecordLike(data.data) + ? data.data + : isRecordLike(data) && data.id !== undefined + ? data + : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The created time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/delete_checklist.ts b/apps/sim/tools/clickup/delete_checklist.ts new file mode 100644 index 00000000000..bac8bccdb6c --- /dev/null +++ b/apps/sim/tools/clickup/delete_checklist.ts @@ -0,0 +1,64 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteChecklistParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteChecklistTool: ToolConfig< + ClickUpDeleteChecklistParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_checklist', + name: 'ClickUp Delete Checklist', + description: 'Delete a checklist from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted checklist', optional: true }, + deleted: { type: 'boolean', description: 'Whether the checklist was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_checklist_item.ts b/apps/sim/tools/clickup/delete_checklist_item.ts new file mode 100644 index 00000000000..0941a3371be --- /dev/null +++ b/apps/sim/tools/clickup/delete_checklist_item.ts @@ -0,0 +1,71 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteChecklistItemParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteChecklistItemTool: ToolConfig< + ClickUpDeleteChecklistItemParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_checklist_item', + name: 'ClickUp Delete Checklist Item', + description: 'Delete an item from a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist containing the item', + }, + checklistItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist item to delete', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item/${encodeURIComponent(params.checklistItemId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistItemId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted checklist item', optional: true }, + deleted: { type: 'boolean', description: 'Whether the item was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_comment.ts b/apps/sim/tools/clickup/delete_comment.ts new file mode 100644 index 00000000000..2ad8005f55f --- /dev/null +++ b/apps/sim/tools/clickup/delete_comment.ts @@ -0,0 +1,64 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteCommentParams, ClickUpDeleteResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteCommentTool: ToolConfig< + ClickUpDeleteCommentParams, + ClickUpDeleteResponse +> = { + id: 'clickup_delete_comment', + name: 'ClickUp Delete Comment', + description: 'Delete a comment from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the comment to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/comment/${encodeURIComponent(params.commentId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete comment') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.commentId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted comment', optional: true }, + deleted: { type: 'boolean', description: 'Whether the comment was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_task.ts b/apps/sim/tools/clickup/delete_task.ts new file mode 100644 index 00000000000..76f41ade6ef --- /dev/null +++ b/apps/sim/tools/clickup/delete_task.ts @@ -0,0 +1,61 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteResponse, ClickUpDeleteTaskParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteTaskTool: ToolConfig = { + id: 'clickup_delete_task', + name: 'ClickUp Delete Task', + description: 'Delete a task from ClickUp', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to delete', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to delete task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.taskId, deleted: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the deleted task', optional: true }, + deleted: { type: 'boolean', description: 'Whether the task was deleted', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/delete_time_entry.ts b/apps/sim/tools/clickup/delete_time_entry.ts new file mode 100644 index 00000000000..47c2f9927fd --- /dev/null +++ b/apps/sim/tools/clickup/delete_time_entry.ts @@ -0,0 +1,86 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpDeleteTimeEntryParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupDeleteTimeEntryTool: ToolConfig< + ClickUpDeleteTimeEntryParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_delete_time_entry', + name: 'ClickUp Delete Time Entry', + description: 'Delete a time entry from a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the entry belongs to', + }, + timerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to delete', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/${encodeURIComponent(params.timerId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to delete time entry') + return { success: false, output: { error }, error } + } + + const payload = isRecordLike(data) ? data.data : null + const entry = Array.isArray(payload) + ? (payload.find((item) => isRecordLike(item)) ?? null) + : isRecordLike(payload) + ? payload + : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The deleted time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_comments.ts b/apps/sim/tools/clickup/get_comments.ts new file mode 100644 index 00000000000..bd1643e35c8 --- /dev/null +++ b/apps/sim/tools/clickup/get_comments.ts @@ -0,0 +1,98 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_COMMENT_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpComment, +} from '@/tools/clickup/shared' +import type { ClickUpCommentListResponse, ClickUpGetCommentsParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetCommentsTool: ToolConfig< + ClickUpGetCommentsParams, + ClickUpCommentListResponse +> = { + id: 'clickup_get_comments', + name: 'ClickUp Get Comments', + description: + 'Retrieve comments on a ClickUp task, newest first (25 per page; paginate with start and startId)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to fetch comments from', + }, + start: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Unix timestamp (ms) of the reference comment for pagination (use the date of the last comment from the previous page, together with startId)', + }, + startId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the reference comment for pagination (use the id of the last comment from the previous page, together with start)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/comment` + ) + if (params.start !== undefined) url.searchParams.set('start', String(params.start)) + if (params.startId) url.searchParams.set('start_id', params.startId) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get comments') + return { success: false, output: { error }, error } + } + + const rawComments = Array.isArray(data?.comments) ? data.comments : [] + + return { + success: true, + output: { comments: rawComments.map((comment: unknown) => mapClickUpComment(comment)) }, + } + }, + + outputs: { + comments: { + type: 'array', + description: 'Comments on the task, newest first', + optional: true, + items: { + type: 'object', + properties: CLICKUP_COMMENT_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_custom_fields.ts b/apps/sim/tools/clickup/get_custom_fields.ts new file mode 100644 index 00000000000..9f41bc48f10 --- /dev/null +++ b/apps/sim/tools/clickup/get_custom_fields.ts @@ -0,0 +1,101 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpCustomField, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldListResponse, + ClickUpGetCustomFieldsParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetCustomFieldsTool: ToolConfig< + ClickUpGetCustomFieldsParams, + ClickUpCustomFieldListResponse +> = { + id: 'clickup_get_custom_fields', + name: 'ClickUp Get Custom Fields', + description: 'List the custom fields accessible in a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to fetch custom fields from', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/field`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get custom fields') + return { success: false, output: { error }, error } + } + + const rawFields = Array.isArray(data?.fields) ? data.fields : [] + + return { + success: true, + output: { fields: rawFields.map((field: unknown) => mapClickUpCustomField(field)) }, + } + }, + + outputs: { + fields: { + type: 'array', + description: 'Custom fields accessible in the list', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Custom field ID' }, + name: { type: 'string', description: 'Custom field name', nullable: true }, + type: { + type: 'string', + description: 'Custom field type (e.g. text, number, drop_down)', + nullable: true, + }, + typeConfig: { + type: 'json', + description: 'Type-specific configuration (e.g. dropdown options)', + nullable: true, + }, + dateCreated: { + type: 'string', + description: 'Creation timestamp (Unix ms)', + nullable: true, + }, + hideFromGuests: { + type: 'boolean', + description: 'Whether the field is hidden from guests', + nullable: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_folders.ts b/apps/sim/tools/clickup/get_folders.ts new file mode 100644 index 00000000000..bb100b96b54 --- /dev/null +++ b/apps/sim/tools/clickup/get_folders.ts @@ -0,0 +1,88 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_FOLDER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpFolder, +} from '@/tools/clickup/shared' +import type { ClickUpFolderListResponse, ClickUpGetFoldersParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetFoldersTool: ToolConfig = + { + id: 'clickup_get_folders', + name: 'ClickUp Get Folders', + description: 'List the folders in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to list folders from', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived folders', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/folder` + ) + if (params.archived !== undefined) { + url.searchParams.set('archived', String(params.archived)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get folders') + return { success: false, output: { error }, error } + } + + const rawFolders = Array.isArray(data?.folders) ? data.folders : [] + + return { + success: true, + output: { folders: rawFolders.map((folder: unknown) => mapClickUpFolder(folder)) }, + } + }, + + outputs: { + folders: { + type: 'array', + description: 'Folders in the space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_FOLDER_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickup/get_list_members.ts b/apps/sim/tools/clickup/get_list_members.ts new file mode 100644 index 00000000000..22723efe5a1 --- /dev/null +++ b/apps/sim/tools/clickup/get_list_members.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_MEMBER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpMember, +} from '@/tools/clickup/shared' +import type { ClickUpGetListMembersParams, ClickUpMemberListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetListMembersTool: ToolConfig< + ClickUpGetListMembersParams, + ClickUpMemberListResponse +> = { + id: 'clickup_get_list_members', + name: 'ClickUp Get List Members', + description: 'List the workspace members who have explicit access to a ClickUp list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to list members for', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/member`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get list members') + return { success: false, output: { error }, error } + } + + const rawMembers = Array.isArray(data?.members) ? data.members : [] + + return { + success: true, + output: { members: rawMembers.map((member: unknown) => mapClickUpMember(member)) }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members with explicit access to the list', + optional: true, + items: { + type: 'object', + properties: CLICKUP_MEMBER_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_lists.ts b/apps/sim/tools/clickup/get_lists.ts new file mode 100644 index 00000000000..f9c0852cb47 --- /dev/null +++ b/apps/sim/tools/clickup/get_lists.ts @@ -0,0 +1,101 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_LIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpList, +} from '@/tools/clickup/shared' +import type { ClickUpGetListsParams, ClickUpListListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetListsTool: ToolConfig = { + id: 'clickup_get_lists', + name: 'ClickUp Get Lists', + description: + 'List the lists in a ClickUp folder, or the folderless lists in a space when a space ID is provided instead', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the folder to list lists from (provide this or spaceId; folderId takes precedence when both are set)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the space to list folderless lists from (provide this or folderId)', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived lists', + }, + }, + + request: { + url: (params) => { + const base = params.folderId + ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(params.folderId)}/list` + : params.spaceId + ? `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/list` + : null + + if (!base) { + throw new Error('Either a folder ID or a space ID is required to get lists') + } + + const url = new URL(base) + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get lists') + return { success: false, output: { error }, error } + } + + const rawLists = Array.isArray(data?.lists) ? data.lists : [] + + return { + success: true, + output: { lists: rawLists.map((list: unknown) => mapClickUpList(list)) }, + } + }, + + outputs: { + lists: { + type: 'array', + description: 'Lists in the folder or space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_LIST_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_running_timer.ts b/apps/sim/tools/clickup/get_running_timer.ts new file mode 100644 index 00000000000..9c616839068 --- /dev/null +++ b/apps/sim/tools/clickup/get_running_timer.ts @@ -0,0 +1,88 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpGetRunningTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetRunningTimerTool: ToolConfig< + ClickUpGetRunningTimerParams, + ClickUpTimeEntryResponse +> = { + id: 'clickup_get_running_timer', + name: 'ClickUp Get Running Timer', + description: + 'Get the currently running time entry in a ClickUp workspace (null when no timer is running)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to check', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to check instead of the authenticated user (owners/admins only)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/current` + ) + if (params.assignee !== undefined) url.searchParams.set('assignee', String(params.assignee)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get running timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: + 'The running time entry (duration is negative while running); null when no timer is running', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_space_tags.ts b/apps/sim/tools/clickup/get_space_tags.ts new file mode 100644 index 00000000000..5b138e7dd17 --- /dev/null +++ b/apps/sim/tools/clickup/get_space_tags.ts @@ -0,0 +1,84 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TAG_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTag, +} from '@/tools/clickup/shared' +import type { + ClickUpGetSpaceTagsParams, + ClickUpTag, + ClickUpTagListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetSpaceTagsTool: ToolConfig< + ClickUpGetSpaceTagsParams, + ClickUpTagListResponse +> = { + id: 'clickup_get_space_tags', + name: 'ClickUp Get Space Tags', + description: 'List the task tags available in a ClickUp space', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + spaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to list tags from', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(params.spaceId)}/tag`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get space tags') + return { success: false, output: { error }, error } + } + + const rawTags = Array.isArray(data?.tags) ? data.tags : [] + + return { + success: true, + output: { + tags: rawTags + .map((tag: unknown) => mapClickUpTag(tag)) + .filter((tag: ClickUpTag | null): tag is ClickUpTag => tag !== null), + }, + } + }, + + outputs: { + tags: { + type: 'array', + description: 'Tags available in the space', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TAG_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_spaces.ts b/apps/sim/tools/clickup/get_spaces.ts new file mode 100644 index 00000000000..b2ebd414f2d --- /dev/null +++ b/apps/sim/tools/clickup/get_spaces.ts @@ -0,0 +1,105 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpSpace, +} from '@/tools/clickup/shared' +import type { ClickUpGetSpacesParams, ClickUpSpaceListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetSpacesTool: ToolConfig = { + id: 'clickup_get_spaces', + name: 'ClickUp Get Spaces', + description: 'List the spaces in a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to list spaces from', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived spaces', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/space` + ) + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get spaces') + return { success: false, output: { error }, error } + } + + const rawSpaces = Array.isArray(data?.spaces) ? data.spaces : [] + + return { + success: true, + output: { spaces: rawSpaces.map((space: unknown) => mapClickUpSpace(space)) }, + } + }, + + outputs: { + spaces: { + type: 'array', + description: 'Spaces in the workspace', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + private: { type: 'boolean', description: 'Whether the space is private', nullable: true }, + archived: { + type: 'boolean', + description: 'Whether the space is archived', + nullable: true, + }, + statuses: { + type: 'array', + description: 'Task statuses available in the space', + items: { + type: 'object', + properties: { + status: { type: 'string', description: 'Status name', nullable: true }, + color: { type: 'string', description: 'Status color', nullable: true }, + type: { type: 'string', description: 'Status type', nullable: true }, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_task.ts b/apps/sim/tools/clickup/get_task.ts new file mode 100644 index 00000000000..658cb0e08c7 --- /dev/null +++ b/apps/sim/tools/clickup/get_task.ts @@ -0,0 +1,92 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpGetTaskParams, ClickUpTaskResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTaskTool: ToolConfig = { + id: 'clickup_get_task', + name: 'ClickUp Get Task', + description: 'Retrieve a task from ClickUp by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to retrieve', + }, + includeSubtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks in the response', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return the task description in Markdown format', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`) + if (params.includeSubtasks !== undefined) { + url.searchParams.set('include_subtasks', String(params.includeSubtasks)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The requested task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_task_members.ts b/apps/sim/tools/clickup/get_task_members.ts new file mode 100644 index 00000000000..57112c5767f --- /dev/null +++ b/apps/sim/tools/clickup/get_task_members.ts @@ -0,0 +1,76 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_MEMBER_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpMember, +} from '@/tools/clickup/shared' +import type { ClickUpGetTaskMembersParams, ClickUpMemberListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTaskMembersTool: ToolConfig< + ClickUpGetTaskMembersParams, + ClickUpMemberListResponse +> = { + id: 'clickup_get_task_members', + name: 'ClickUp Get Task Members', + description: 'List the workspace members who have explicit access to a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to list members for', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/member`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get task members') + return { success: false, output: { error }, error } + } + + const rawMembers = Array.isArray(data?.members) ? data.members : [] + + return { + success: true, + output: { members: rawMembers.map((member: unknown) => mapClickUpMember(member)) }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members with explicit access to the task', + optional: true, + items: { + type: 'object', + properties: CLICKUP_MEMBER_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_tasks.ts b/apps/sim/tools/clickup/get_tasks.ts new file mode 100644 index 00000000000..ab63dd1f1cf --- /dev/null +++ b/apps/sim/tools/clickup/get_tasks.ts @@ -0,0 +1,190 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpGetTasksParams, ClickUpTaskListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTasksTool: ToolConfig = { + id: 'clickup_get_tasks', + name: 'ClickUp Get Tasks', + description: + 'List the tasks in a ClickUp list (100 tasks per page; increment page until an empty result to paginate)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the list to fetch tasks from', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to fetch (starts at 0)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order by field: id, created, updated, or due_date', + }, + reverse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return tasks in reverse order', + }, + subtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks (excluded by default)', + }, + includeClosed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include closed tasks (excluded by default)', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return task descriptions in Markdown format', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return archived tasks', + }, + statuses: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by status names', + items: { + type: 'string', + description: 'A status name', + }, + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by assignee user IDs', + items: { + type: 'string', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by tag names', + items: { + type: 'string', + description: 'A tag name', + }, + }, + dueDateGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due after this Unix timestamp in milliseconds', + }, + dueDateLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due before this Unix timestamp in milliseconds', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${CLICKUP_API_BASE_URL}/list/${encodeURIComponent(params.listId)}/task`) + if (params.page !== undefined) url.searchParams.set('page', String(params.page)) + if (params.orderBy) url.searchParams.set('order_by', params.orderBy) + if (params.reverse !== undefined) url.searchParams.set('reverse', String(params.reverse)) + if (params.subtasks !== undefined) url.searchParams.set('subtasks', String(params.subtasks)) + if (params.includeClosed !== undefined) { + url.searchParams.set('include_closed', String(params.includeClosed)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + if (params.archived !== undefined) url.searchParams.set('archived', String(params.archived)) + for (const status of params.statuses ?? []) { + url.searchParams.append('statuses[]', status) + } + for (const assignee of params.assignees ?? []) { + url.searchParams.append('assignees[]', assignee) + } + for (const tag of params.tags ?? []) { + url.searchParams.append('tags[]', tag) + } + if (params.dueDateGt !== undefined) { + url.searchParams.set('due_date_gt', String(params.dueDateGt)) + } + if (params.dueDateLt !== undefined) { + url.searchParams.set('due_date_lt', String(params.dueDateLt)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get tasks') + return { success: false, output: { error }, error } + } + + const rawTasks = Array.isArray(data?.tasks) ? data.tasks : [] + + return { + success: true, + output: { tasks: rawTasks.map((task: unknown) => mapClickUpTask(task)) }, + } + }, + + outputs: { + tasks: { + type: 'array', + description: 'Tasks in the list', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_time_entries.ts b/apps/sim/tools/clickup/get_time_entries.ts new file mode 100644 index 00000000000..5f447637feb --- /dev/null +++ b/apps/sim/tools/clickup/get_time_entries.ts @@ -0,0 +1,155 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { + ClickUpGetTimeEntriesParams, + ClickUpTimeEntryListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetTimeEntriesTool: ToolConfig< + ClickUpGetTimeEntriesParams, + ClickUpTimeEntryListResponse +> = { + id: 'clickup_get_time_entries', + name: 'ClickUp Get Time Entries', + description: + 'List time entries in a ClickUp workspace within a date range (defaults to the last 30 days for the authenticated user)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to list time entries from', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start of the date range as a Unix timestamp in milliseconds', + }, + endDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'End of the date range as a Unix timestamp in milliseconds', + }, + assignee: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by user IDs, comma-separated (requires workspace owner/admin to view others)', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this task (use at most one location filter)', + }, + listId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this list (use at most one location filter)', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this folder (use at most one location filter)', + }, + spaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries in this space (use at most one location filter)', + }, + includeTaskTags: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include task tags in the response', + }, + includeLocationNames: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include list, folder, and space names in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries` + ) + if (params.startDate !== undefined) { + url.searchParams.set('start_date', String(params.startDate)) + } + if (params.endDate !== undefined) url.searchParams.set('end_date', String(params.endDate)) + if (params.assignee) url.searchParams.set('assignee', params.assignee) + if (params.taskId) url.searchParams.set('task_id', params.taskId) + if (params.listId) url.searchParams.set('list_id', params.listId) + if (params.folderId) url.searchParams.set('folder_id', params.folderId) + if (params.spaceId) url.searchParams.set('space_id', params.spaceId) + if (params.includeTaskTags !== undefined) { + url.searchParams.set('include_task_tags', String(params.includeTaskTags)) + } + if (params.includeLocationNames !== undefined) { + url.searchParams.set('include_location_names', String(params.includeLocationNames)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get time entries') + return { success: false, output: { error }, error } + } + + const rawEntries = Array.isArray(data?.data) ? data.data : [] + + return { + success: true, + output: { timeEntries: rawEntries.map((entry: unknown) => mapClickUpTimeEntry(entry)) }, + } + }, + + outputs: { + timeEntries: { + type: 'array', + description: 'Time entries in the date range', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/get_workspaces.ts b/apps/sim/tools/clickup/get_workspaces.ts new file mode 100644 index 00000000000..36d78e8de9f --- /dev/null +++ b/apps/sim/tools/clickup/get_workspaces.ts @@ -0,0 +1,77 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpWorkspace, +} from '@/tools/clickup/shared' +import type { + ClickUpGetWorkspacesParams, + ClickUpWorkspaceListResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupGetWorkspacesTool: ToolConfig< + ClickUpGetWorkspacesParams, + ClickUpWorkspaceListResponse +> = { + id: 'clickup_get_workspaces', + name: 'ClickUp Get Workspaces', + description: 'List the ClickUp workspaces (teams) available to the connected account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + }, + + request: { + url: `${CLICKUP_API_BASE_URL}/team`, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to get workspaces') + return { success: false, output: { error }, error } + } + + const rawTeams = Array.isArray(data?.teams) ? data.teams : [] + + return { + success: true, + output: { workspaces: rawTeams.map((team: unknown) => mapClickUpWorkspace(team)) }, + } + }, + + outputs: { + workspaces: { + type: 'array', + description: 'Workspaces available to the connected account', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Workspace ID' }, + name: { type: 'string', description: 'Workspace name', nullable: true }, + color: { type: 'string', description: 'Workspace color', nullable: true }, + avatar: { type: 'string', description: 'Workspace avatar URL', nullable: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/clickup/index.ts b/apps/sim/tools/clickup/index.ts new file mode 100644 index 00000000000..d01a279b2bf --- /dev/null +++ b/apps/sim/tools/clickup/index.ts @@ -0,0 +1,77 @@ +import { clickupAddTagToTaskTool } from '@/tools/clickup/add_tag_to_task' +import { clickupCreateChecklistTool } from '@/tools/clickup/create_checklist' +import { clickupCreateChecklistItemTool } from '@/tools/clickup/create_checklist_item' +import { clickupCreateCommentTool } from '@/tools/clickup/create_comment' +import { clickupCreateFolderTool } from '@/tools/clickup/create_folder' +import { clickupCreateListTool } from '@/tools/clickup/create_list' +import { clickupCreateTaskTool } from '@/tools/clickup/create_task' +import { clickupCreateTimeEntryTool } from '@/tools/clickup/create_time_entry' +import { clickupDeleteChecklistTool } from '@/tools/clickup/delete_checklist' +import { clickupDeleteChecklistItemTool } from '@/tools/clickup/delete_checklist_item' +import { clickupDeleteCommentTool } from '@/tools/clickup/delete_comment' +import { clickupDeleteTaskTool } from '@/tools/clickup/delete_task' +import { clickupDeleteTimeEntryTool } from '@/tools/clickup/delete_time_entry' +import { clickupGetCommentsTool } from '@/tools/clickup/get_comments' +import { clickupGetCustomFieldsTool } from '@/tools/clickup/get_custom_fields' +import { clickupGetFoldersTool } from '@/tools/clickup/get_folders' +import { clickupGetListMembersTool } from '@/tools/clickup/get_list_members' +import { clickupGetListsTool } from '@/tools/clickup/get_lists' +import { clickupGetRunningTimerTool } from '@/tools/clickup/get_running_timer' +import { clickupGetSpaceTagsTool } from '@/tools/clickup/get_space_tags' +import { clickupGetSpacesTool } from '@/tools/clickup/get_spaces' +import { clickupGetTaskTool } from '@/tools/clickup/get_task' +import { clickupGetTaskMembersTool } from '@/tools/clickup/get_task_members' +import { clickupGetTasksTool } from '@/tools/clickup/get_tasks' +import { clickupGetTimeEntriesTool } from '@/tools/clickup/get_time_entries' +import { clickupGetWorkspacesTool } from '@/tools/clickup/get_workspaces' +import { clickupRemoveCustomFieldValueTool } from '@/tools/clickup/remove_custom_field_value' +import { clickupRemoveTagFromTaskTool } from '@/tools/clickup/remove_tag_from_task' +import { clickupSearchTasksTool } from '@/tools/clickup/search_tasks' +import { clickupSetCustomFieldValueTool } from '@/tools/clickup/set_custom_field_value' +import { clickupStartTimerTool } from '@/tools/clickup/start_timer' +import { clickupStopTimerTool } from '@/tools/clickup/stop_timer' +import { clickupUpdateChecklistTool } from '@/tools/clickup/update_checklist' +import { clickupUpdateChecklistItemTool } from '@/tools/clickup/update_checklist_item' +import { clickupUpdateCommentTool } from '@/tools/clickup/update_comment' +import { clickupUpdateTaskTool } from '@/tools/clickup/update_task' +import { clickupUpdateTimeEntryTool } from '@/tools/clickup/update_time_entry' +import { clickupUploadAttachmentTool } from '@/tools/clickup/upload_attachment' + +export { clickupAddTagToTaskTool } +export { clickupCreateChecklistTool } +export { clickupCreateChecklistItemTool } +export { clickupCreateCommentTool } +export { clickupCreateFolderTool } +export { clickupCreateListTool } +export { clickupCreateTaskTool } +export { clickupCreateTimeEntryTool } +export { clickupDeleteChecklistTool } +export { clickupDeleteChecklistItemTool } +export { clickupDeleteCommentTool } +export { clickupDeleteTaskTool } +export { clickupDeleteTimeEntryTool } +export { clickupGetCommentsTool } +export { clickupGetCustomFieldsTool } +export { clickupGetFoldersTool } +export { clickupGetListMembersTool } +export { clickupGetListsTool } +export { clickupGetRunningTimerTool } +export { clickupGetSpaceTagsTool } +export { clickupGetSpacesTool } +export { clickupGetTaskTool } +export { clickupGetTaskMembersTool } +export { clickupGetTasksTool } +export { clickupGetTimeEntriesTool } +export { clickupGetWorkspacesTool } +export { clickupRemoveCustomFieldValueTool } +export { clickupRemoveTagFromTaskTool } +export { clickupSearchTasksTool } +export { clickupSetCustomFieldValueTool } +export { clickupStartTimerTool } +export { clickupStopTimerTool } +export { clickupUpdateChecklistTool } +export { clickupUpdateChecklistItemTool } +export { clickupUpdateCommentTool } +export { clickupUpdateTaskTool } +export { clickupUpdateTimeEntryTool } +export { clickupUploadAttachmentTool } diff --git a/apps/sim/tools/clickup/remove_custom_field_value.ts b/apps/sim/tools/clickup/remove_custom_field_value.ts new file mode 100644 index 00000000000..63f44764198 --- /dev/null +++ b/apps/sim/tools/clickup/remove_custom_field_value.ts @@ -0,0 +1,83 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldValueResponse, + ClickUpRemoveCustomFieldValueParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupRemoveCustomFieldValueTool: ToolConfig< + ClickUpRemoveCustomFieldValueParams, + ClickUpCustomFieldValueResponse +> = { + id: 'clickup_remove_custom_field_value', + name: 'ClickUp Remove Custom Field Value', + description: + 'Remove the value of a custom field from a ClickUp task (does not delete the field itself)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to remove the custom field value from', + }, + fieldId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the custom field to clear', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/field/${encodeURIComponent(params.fieldId)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage( + response, + data, + 'Failed to remove custom field value' + ) + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, fieldId: params?.fieldId }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the updated task', optional: true }, + fieldId: { + type: 'string', + description: 'ID of the custom field that was cleared', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/clickup/remove_tag_from_task.ts b/apps/sim/tools/clickup/remove_tag_from_task.ts new file mode 100644 index 00000000000..b34a984c996 --- /dev/null +++ b/apps/sim/tools/clickup/remove_tag_from_task.ts @@ -0,0 +1,71 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { ClickUpTaskTagParams, ClickUpTaskTagResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupRemoveTagFromTaskTool: ToolConfig< + ClickUpTaskTagParams, + ClickUpTaskTagResponse +> = { + id: 'clickup_remove_tag_from_task', + name: 'ClickUp Remove Tag From Task', + description: 'Remove a tag from a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to remove the tag from', + }, + tagName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the tag to remove', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/tag/${encodeURIComponent(params.tagName)}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to remove tag from task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, tagName: params?.tagName }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the task', optional: true }, + tagName: { type: 'string', description: 'Name of the tag that was removed', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/search_tasks.ts b/apps/sim/tools/clickup/search_tasks.ts new file mode 100644 index 00000000000..1247add76e1 --- /dev/null +++ b/apps/sim/tools/clickup/search_tasks.ts @@ -0,0 +1,227 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpSearchTasksParams, ClickUpTaskListResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupSearchTasksTool: ToolConfig = + { + id: 'clickup_search_tasks', + name: 'ClickUp Search Tasks', + description: + 'Search tasks across a ClickUp workspace with filters for lists, folders, spaces, statuses, assignees, tags, and due dates (100 tasks per page; increment page until an empty result to paginate)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to search tasks in', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page to fetch (starts at 0)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order by field: id, created, updated, or due_date', + }, + reverse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return tasks in reverse order', + }, + subtasks: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include subtasks (excluded by default)', + }, + includeClosed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include closed tasks (excluded by default)', + }, + includeMarkdownDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return task descriptions in Markdown format', + }, + listIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by list IDs', + items: { + type: 'string', + description: 'A ClickUp list ID', + }, + }, + spaceIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by space IDs', + items: { + type: 'string', + description: 'A ClickUp space ID', + }, + }, + folderIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by folder IDs', + items: { + type: 'string', + description: 'A ClickUp folder ID', + }, + }, + statuses: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by status names', + items: { + type: 'string', + description: 'A status name', + }, + }, + assignees: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by assignee user IDs', + items: { + type: 'string', + description: 'A ClickUp user ID', + }, + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by tag names', + items: { + type: 'string', + description: 'A tag name', + }, + }, + dueDateGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due after this Unix timestamp in milliseconds', + }, + dueDateLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only tasks due before this Unix timestamp in milliseconds', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/task` + ) + if (params.page !== undefined) url.searchParams.set('page', String(params.page)) + if (params.orderBy) url.searchParams.set('order_by', params.orderBy) + if (params.reverse !== undefined) url.searchParams.set('reverse', String(params.reverse)) + if (params.subtasks !== undefined) { + url.searchParams.set('subtasks', String(params.subtasks)) + } + if (params.includeClosed !== undefined) { + url.searchParams.set('include_closed', String(params.includeClosed)) + } + if (params.includeMarkdownDescription !== undefined) { + url.searchParams.set( + 'include_markdown_description', + String(params.includeMarkdownDescription) + ) + } + for (const listId of params.listIds ?? []) { + url.searchParams.append('list_ids[]', listId) + } + for (const spaceId of params.spaceIds ?? []) { + url.searchParams.append('space_ids[]', spaceId) + } + for (const folderId of params.folderIds ?? []) { + url.searchParams.append('project_ids[]', folderId) + } + for (const status of params.statuses ?? []) { + url.searchParams.append('statuses[]', status) + } + for (const assignee of params.assignees ?? []) { + url.searchParams.append('assignees[]', assignee) + } + for (const tag of params.tags ?? []) { + url.searchParams.append('tags[]', tag) + } + if (params.dueDateGt !== undefined) { + url.searchParams.set('due_date_gt', String(params.dueDateGt)) + } + if (params.dueDateLt !== undefined) { + url.searchParams.set('due_date_lt', String(params.dueDateLt)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to search tasks') + return { success: false, output: { error }, error } + } + + const rawTasks = Array.isArray(data?.tasks) ? data.tasks : [] + + return { + success: true, + output: { tasks: rawTasks.map((task: unknown) => mapClickUpTask(task)) }, + } + }, + + outputs: { + tasks: { + type: 'array', + description: 'Tasks matching the filters', + optional: true, + items: { + type: 'object', + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickup/set_custom_field_value.ts b/apps/sim/tools/clickup/set_custom_field_value.ts new file mode 100644 index 00000000000..cd446d5471c --- /dev/null +++ b/apps/sim/tools/clickup/set_custom_field_value.ts @@ -0,0 +1,84 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpCustomFieldValueResponse, + ClickUpSetCustomFieldValueParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupSetCustomFieldValueTool: ToolConfig< + ClickUpSetCustomFieldValueParams, + ClickUpCustomFieldValueResponse +> = { + id: 'clickup_set_custom_field_value', + name: 'ClickUp Set Custom Field Value', + description: + 'Set the value of a custom field on a ClickUp task (the value shape depends on the field type)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to set the custom field on', + }, + fieldId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'UUID of the custom field (find it with the Get Custom Fields or Get Task operations)', + }, + value: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Value to set. The shape depends on the field type: text/number fields take a plain value, label fields take an array of option UUIDs, dropdown fields take an option UUID', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}/field/${encodeURIComponent(params.fieldId)}`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => ({ value: params.value }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to set custom field value') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { taskId: params?.taskId, fieldId: params?.fieldId }, + } + }, + + outputs: { + taskId: { type: 'string', description: 'ID of the updated task', optional: true }, + fieldId: { type: 'string', description: 'ID of the custom field that was set', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/shared.ts b/apps/sim/tools/clickup/shared.ts new file mode 100644 index 00000000000..2d3cebec811 --- /dev/null +++ b/apps/sim/tools/clickup/shared.ts @@ -0,0 +1,707 @@ +import { isRecordLike } from '@sim/utils/object' +import type { + ClickUpAttachment, + ClickUpChecklist, + ClickUpChecklistItem, + ClickUpComment, + ClickUpCustomField, + ClickUpFolder, + ClickUpList, + ClickUpMember, + ClickUpPriority, + ClickUpSpace, + ClickUpStatus, + ClickUpTag, + ClickUpTask, + ClickUpTimeEntry, + ClickUpUser, + ClickUpWorkspace, +} from '@/tools/clickup/types' +import type { OutputProperty } from '@/tools/types' + +export const CLICKUP_API_BASE_URL = 'https://api.clickup.com/api/v2' + +/** + * Builds the Authorization header value for ClickUp API requests. + * + * ClickUp documents two credential shapes: OAuth access tokens are sent as + * `Authorization: Bearer `, while personal API tokens (prefixed with + * `pk_`) must be sent bare as `Authorization: ` with no scheme. + * This helper detects the personal-token prefix and returns the correct form + * so tools work with both OAuth connections and pasted API tokens. + * + * @param accessToken - OAuth access token or ClickUp personal API token + * @returns The value to use for the `Authorization` header + */ +export function clickupAuthorizationHeader(accessToken: string): string { + return accessToken.startsWith('pk_') ? accessToken : `Bearer ${accessToken}` +} + +function getRequiredString(value: unknown, field: string): string { + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + throw new Error(`ClickUp response is missing required field: ${field}`) +} + +function getOptionalString(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + return null +} + +function getOptionalBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function getOptionalNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return null +} + +const CLICKUP_USER_OUTPUT_PROPERTIES: Record = { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, +} + +export const CLICKUP_TASK_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Task ID' }, + customId: { type: 'string', description: 'Custom task ID', nullable: true }, + name: { type: 'string', description: 'Task name' }, + textContent: { type: 'string', description: 'Plain text content', nullable: true }, + description: { type: 'string', description: 'Task description', nullable: true }, + markdownDescription: { + type: 'string', + description: 'Task description in Markdown (present when requested)', + nullable: true, + }, + status: { + type: 'object', + description: 'Task status', + nullable: true, + properties: { + status: { type: 'string', description: 'Status name', nullable: true }, + color: { type: 'string', description: 'Status color', nullable: true }, + type: { type: 'string', description: 'Status type (open, closed, custom)', nullable: true }, + }, + }, + archived: { type: 'boolean', description: 'Whether the task is archived' }, + creator: { + type: 'object', + description: 'Task creator', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + assignees: { + type: 'array', + description: 'Users assigned to the task', + items: { type: 'object', properties: CLICKUP_USER_OUTPUT_PROPERTIES }, + }, + watchers: { + type: 'array', + description: 'Users watching the task', + items: { type: 'object', properties: CLICKUP_USER_OUTPUT_PROPERTIES }, + }, + tags: { + type: 'array', + description: 'Tags applied to the task', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Tag name', nullable: true }, + tagFg: { type: 'string', description: 'Tag foreground color', nullable: true }, + tagBg: { type: 'string', description: 'Tag background color', nullable: true }, + }, + }, + }, + parent: { type: 'string', description: 'Parent task ID', nullable: true }, + priority: { + type: 'object', + description: 'Task priority', + nullable: true, + properties: { + id: { type: 'string', description: 'Priority ID', nullable: true }, + priority: { type: 'string', description: 'Priority name', nullable: true }, + color: { type: 'string', description: 'Priority color', nullable: true }, + }, + }, + dueDate: { type: 'string', description: 'Due date (Unix ms)', nullable: true }, + startDate: { type: 'string', description: 'Start date (Unix ms)', nullable: true }, + points: { type: 'number', description: 'Sprint points', nullable: true }, + timeEstimate: { type: 'number', description: 'Time estimate in milliseconds', nullable: true }, + timeSpent: { type: 'number', description: 'Time tracked in milliseconds', nullable: true }, + customFields: { + type: 'json', + description: 'Custom field values on the task (id, name, type, value)', + }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + dateUpdated: { type: 'string', description: 'Last update timestamp (Unix ms)', nullable: true }, + dateClosed: { type: 'string', description: 'Closed timestamp (Unix ms)', nullable: true }, + dateDone: { type: 'string', description: 'Done timestamp (Unix ms)', nullable: true }, + list: { + type: 'object', + description: 'List containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name', nullable: true }, + }, + }, + folder: { + type: 'object', + description: 'Folder containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + }, + }, + space: { + type: 'object', + description: 'Space containing the task', + nullable: true, + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + }, + }, + url: { type: 'string', description: 'URL to the task in ClickUp', nullable: true }, + subtasks: { + type: 'json', + description: 'Subtasks with the same shape as the task object (present when requested)', + nullable: true, + }, +} + +export const CLICKUP_MEMBER_OUTPUT_PROPERTIES: Record = { + id: { type: 'number', description: 'Member user ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + color: { type: 'string', description: 'Profile color', nullable: true }, + initials: { type: 'string', description: 'User initials', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, +} + +export const CLICKUP_COMMENT_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Comment ID' }, + commentText: { type: 'string', description: 'Comment text content', nullable: true }, + resolved: { type: 'boolean', description: 'Whether the comment is resolved', nullable: true }, + user: { + type: 'object', + description: 'Comment author', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + assignee: { + type: 'object', + description: 'User the comment is assigned to', + nullable: true, + properties: CLICKUP_USER_OUTPUT_PROPERTIES, + }, + date: { type: 'string', description: 'Comment timestamp (Unix ms)', nullable: true }, + replyCount: { type: 'string', description: 'Number of replies', nullable: true }, +} + +export const CLICKUP_LIST_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name', nullable: true }, + taskCount: { type: 'string', description: 'Number of tasks in the list', nullable: true }, + archived: { type: 'boolean', description: 'Whether the list is archived', nullable: true }, +} + +export const CLICKUP_FOLDER_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Folder ID' }, + name: { type: 'string', description: 'Folder name', nullable: true }, + hidden: { type: 'boolean', description: 'Whether the folder is hidden', nullable: true }, + taskCount: { type: 'string', description: 'Number of tasks in the folder', nullable: true }, + space: { + type: 'object', + description: 'Space containing the folder', + nullable: true, + properties: { + id: { type: 'string', description: 'Space ID' }, + name: { type: 'string', description: 'Space name', nullable: true }, + }, + }, +} + +export const CLICKUP_TAG_OUTPUT_PROPERTIES: Record = { + name: { type: 'string', description: 'Tag name', nullable: true }, + tagFg: { type: 'string', description: 'Tag foreground color', nullable: true }, + tagBg: { type: 'string', description: 'Tag background color', nullable: true }, +} + +export function mapClickUpUser(value: unknown): ClickUpUser | null { + if (!isRecordLike(value)) { + return null + } + + return { + id: getOptionalNumber(value.id), + username: getOptionalString(value.username), + email: getOptionalString(value.email), + profilePicture: getOptionalString(value.profilePicture), + } +} + +function mapClickUpStatus(value: unknown): ClickUpStatus | null { + if (!isRecordLike(value)) { + return null + } + + return { + status: getOptionalString(value.status), + color: getOptionalString(value.color), + type: getOptionalString(value.type), + } +} + +function mapClickUpPriority(value: unknown): ClickUpPriority | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return { id: String(value), priority: null, color: null } + } + + if (!isRecordLike(value)) { + return null + } + + return { + id: getOptionalString(value.id), + priority: getOptionalString(value.priority) ?? getOptionalString(value.name), + color: getOptionalString(value.color), + } +} + +export function mapClickUpTag(value: unknown): ClickUpTag | null { + if (!isRecordLike(value)) { + return null + } + + return { + name: getOptionalString(value.name), + tagFg: getOptionalString(value.tag_fg), + tagBg: getOptionalString(value.tag_bg), + } +} + +function mapIdName(value: unknown): { id: string; name: string | null } | null { + if (!isRecordLike(value)) { + return null + } + + const id = getOptionalString(value.id) + if (!id) { + return null + } + + return { id, name: getOptionalString(value.name) } +} + +export function mapClickUpTask(value: unknown): ClickUpTask { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid task object') + } + + const rawAssignees = Array.isArray(value.assignees) ? value.assignees : [] + const rawWatchers = Array.isArray(value.watchers) ? value.watchers : [] + const rawTags = Array.isArray(value.tags) ? value.tags : [] + const rawCustomFields = Array.isArray(value.custom_fields) ? value.custom_fields : [] + + return { + id: getRequiredString(value.id, 'id'), + customId: getOptionalString(value.custom_id), + name: getRequiredString(value.name, 'name'), + textContent: getOptionalString(value.text_content), + description: getOptionalString(value.description), + markdownDescription: getOptionalString(value.markdown_description), + status: mapClickUpStatus(value.status), + archived: typeof value.archived === 'boolean' ? value.archived : false, + creator: mapClickUpUser(value.creator), + assignees: rawAssignees + .map((assignee) => mapClickUpUser(assignee)) + .filter((assignee): assignee is ClickUpUser => assignee !== null), + watchers: rawWatchers + .map((watcher) => mapClickUpUser(watcher)) + .filter((watcher): watcher is ClickUpUser => watcher !== null), + tags: rawTags.map((tag) => mapClickUpTag(tag)).filter((tag): tag is ClickUpTag => tag !== null), + parent: getOptionalString(value.parent), + priority: mapClickUpPriority(value.priority), + dueDate: getOptionalString(value.due_date), + startDate: getOptionalString(value.start_date), + points: getOptionalNumber(value.points), + timeEstimate: getOptionalNumber(value.time_estimate), + timeSpent: getOptionalNumber(value.time_spent), + customFields: rawCustomFields.filter(isRecordLike), + dateCreated: getOptionalString(value.date_created), + dateUpdated: getOptionalString(value.date_updated), + dateClosed: getOptionalString(value.date_closed), + dateDone: getOptionalString(value.date_done), + list: mapIdName(value.list), + folder: mapIdName(value.folder), + space: mapIdName(value.space), + url: getOptionalString(value.url), + subtasks: Array.isArray(value.subtasks) + ? value.subtasks.map((subtask) => mapClickUpTask(subtask)) + : null, + } +} + +export function mapClickUpComment(value: unknown): ClickUpComment { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid comment object') + } + + return { + id: getRequiredString(value.id, 'id'), + commentText: getOptionalString(value.comment_text), + resolved: getOptionalBoolean(value.resolved), + user: mapClickUpUser(value.user), + assignee: mapClickUpUser(value.assignee), + date: getOptionalString(value.date), + replyCount: getOptionalString(value.reply_count), + } +} + +export function mapClickUpWorkspace(value: unknown): ClickUpWorkspace { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid workspace object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + color: getOptionalString(value.color), + avatar: getOptionalString(value.avatar), + } +} + +export function mapClickUpSpace(value: unknown): ClickUpSpace { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid space object') + } + + const rawStatuses = Array.isArray(value.statuses) ? value.statuses : [] + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + private: getOptionalBoolean(value.private), + archived: getOptionalBoolean(value.archived), + statuses: rawStatuses + .map((status) => mapClickUpStatus(status)) + .filter((status): status is ClickUpStatus => status !== null), + } +} + +export function mapClickUpFolder(value: unknown): ClickUpFolder { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid folder object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + hidden: getOptionalBoolean(value.hidden), + taskCount: getOptionalString(value.task_count), + space: mapIdName(value.space), + } +} + +export function mapClickUpList(value: unknown): ClickUpList { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid list object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + taskCount: getOptionalString(value.task_count), + archived: getOptionalBoolean(value.archived), + } +} + +export function mapClickUpAttachment(value: unknown): ClickUpAttachment { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid attachment object') + } + + return { + id: getRequiredString(value.id, 'id'), + version: getOptionalString(value.version), + title: getOptionalString(value.title), + extension: getOptionalString(value.extension), + url: getOptionalString(value.url), + date: getOptionalNumber(value.date), + thumbnailSmall: getOptionalString(value.thumbnail_small), + thumbnailLarge: getOptionalString(value.thumbnail_large), + } +} + +/** + * Maps a member from the task/list member endpoints. The documented shape is a + * flat user object (`{ id, username, email, ... }`); a nested `{ user: {...} }` + * wrapper (the shape the workspace members endpoint uses) is tolerated too. + */ +export function mapClickUpMember(value: unknown): ClickUpMember { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid member object') + } + + const source = isRecordLike(value.user) ? value.user : value + + return { + id: getOptionalNumber(source.id), + username: getOptionalString(source.username), + email: getOptionalString(source.email), + color: getOptionalString(source.color), + initials: getOptionalString(source.initials), + profilePicture: getOptionalString(source.profilePicture), + } +} + +export function mapClickUpCustomField(value: unknown): ClickUpCustomField { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid custom field object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + type: getOptionalString(value.type), + typeConfig: isRecordLike(value.type_config) ? value.type_config : null, + dateCreated: getOptionalString(value.date_created), + hideFromGuests: getOptionalBoolean(value.hide_from_guests), + } +} + +function mapClickUpChecklistItem(value: unknown): ClickUpChecklistItem { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid checklist item object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getOptionalString(value.name), + orderIndex: getOptionalNumber(value.orderindex), + assignee: mapClickUpUser(value.assignee), + resolved: getOptionalBoolean(value.resolved), + parent: getOptionalString(value.parent), + dateCreated: getOptionalString(value.date_created), + children: Array.isArray(value.children) + ? value.children.filter((child): child is string => typeof child === 'string') + : [], + } +} + +export function mapClickUpChecklist(value: unknown): ClickUpChecklist { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid checklist object') + } + + const rawItems = Array.isArray(value.items) ? value.items : [] + + return { + id: getRequiredString(value.id, 'id'), + taskId: getOptionalString(value.task_id), + name: getOptionalString(value.name), + orderIndex: getOptionalNumber(value.orderindex), + resolved: getOptionalNumber(value.resolved), + unresolved: getOptionalNumber(value.unresolved), + dateCreated: getOptionalString(value.date_created), + items: rawItems.map((item) => mapClickUpChecklistItem(item)), + } +} + +export function mapClickUpTimeEntry(value: unknown): ClickUpTimeEntry { + if (!isRecordLike(value)) { + throw new Error('ClickUp returned an invalid time entry object') + } + + const rawTags = Array.isArray(value.tags) ? value.tags : [] + const rawTaskTags = Array.isArray(value.task_tags) ? value.task_tags : [] + const rawLocation = isRecordLike(value.task_location) ? value.task_location : null + + return { + id: getRequiredString(value.id, 'id'), + task: mapIdName(value.task), + workspaceId: getOptionalString(value.wid), + user: mapClickUpUser(value.user), + billable: getOptionalBoolean(value.billable), + start: getOptionalString(value.start), + end: getOptionalString(value.end), + duration: getOptionalNumber(value.duration), + description: getOptionalString(value.description), + tags: rawTags.map((tag) => mapClickUpTag(tag)).filter((tag): tag is ClickUpTag => tag !== null), + source: getOptionalString(value.source), + at: getOptionalString(value.at), + taskUrl: getOptionalString(value.task_url), + taskTags: rawTaskTags + .map((tag) => mapClickUpTag(tag)) + .filter((tag): tag is ClickUpTag => tag !== null), + taskLocation: rawLocation + ? { + listId: getOptionalString(rawLocation.list_id), + folderId: getOptionalString(rawLocation.folder_id), + spaceId: getOptionalString(rawLocation.space_id), + listName: getOptionalString(rawLocation.list_name), + folderName: getOptionalString(rawLocation.folder_name), + spaceName: getOptionalString(rawLocation.space_name), + } + : null, + } +} + +const CLICKUP_CHECKLIST_ITEM_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name', nullable: true }, + orderIndex: { type: 'number', description: 'Order of the item in the checklist', nullable: true }, + assignee: { + type: 'object', + description: 'User the item is assigned to', + nullable: true, + properties: { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, + }, + }, + resolved: { type: 'boolean', description: 'Whether the item is resolved', nullable: true }, + parent: { type: 'string', description: 'Parent checklist item ID', nullable: true }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + children: { + type: 'array', + description: 'IDs of nested child items', + items: { type: 'string', description: 'A checklist item ID' }, + }, +} + +export const CLICKUP_CHECKLIST_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Checklist ID' }, + taskId: { + type: 'string', + description: 'ID of the task the checklist belongs to', + nullable: true, + }, + name: { type: 'string', description: 'Checklist name', nullable: true }, + orderIndex: { type: 'number', description: 'Order of the checklist on the task', nullable: true }, + resolved: { type: 'number', description: 'Number of resolved items', nullable: true }, + unresolved: { type: 'number', description: 'Number of unresolved items', nullable: true }, + dateCreated: { type: 'string', description: 'Creation timestamp (Unix ms)', nullable: true }, + items: { + type: 'array', + description: 'Items in the checklist', + items: { type: 'object', properties: CLICKUP_CHECKLIST_ITEM_OUTPUT_PROPERTIES }, + }, +} + +export const CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Time entry ID' }, + task: { + type: 'object', + description: 'Task the time entry is associated with', + nullable: true, + properties: { + id: { type: 'string', description: 'Task ID' }, + name: { type: 'string', description: 'Task name', nullable: true }, + }, + }, + workspaceId: { type: 'string', description: 'Workspace ID', nullable: true }, + user: { + type: 'object', + description: 'User the time entry belongs to', + nullable: true, + properties: { + id: { type: 'number', description: 'User ID', nullable: true }, + username: { type: 'string', description: 'Username', nullable: true }, + email: { type: 'string', description: 'User email', nullable: true }, + profilePicture: { type: 'string', description: 'Profile picture URL', nullable: true }, + }, + }, + billable: { type: 'boolean', description: 'Whether the entry is billable', nullable: true }, + start: { type: 'string', description: 'Start timestamp (Unix ms)', nullable: true }, + end: { type: 'string', description: 'End timestamp (Unix ms)', nullable: true }, + duration: { + type: 'number', + description: 'Duration in milliseconds (negative while the timer is running)', + nullable: true, + }, + description: { type: 'string', description: 'Time entry description', nullable: true }, + tags: { + type: 'array', + description: 'Time entry tags', + items: { type: 'object', properties: CLICKUP_TAG_OUTPUT_PROPERTIES }, + }, + source: { type: 'string', description: 'Source that created the entry', nullable: true }, + at: { type: 'string', description: 'Last update timestamp (Unix ms)', nullable: true }, + taskUrl: { type: 'string', description: 'URL to the task in ClickUp', nullable: true }, + taskTags: { + type: 'array', + description: 'Tags on the associated task (present when requested)', + items: { type: 'object', properties: CLICKUP_TAG_OUTPUT_PROPERTIES }, + }, + taskLocation: { + type: 'object', + description: 'Location of the associated task (names present when requested)', + nullable: true, + properties: { + listId: { type: 'string', description: 'List ID', nullable: true }, + folderId: { type: 'string', description: 'Folder ID', nullable: true }, + spaceId: { type: 'string', description: 'Space ID', nullable: true }, + listName: { type: 'string', description: 'List name', nullable: true }, + folderName: { type: 'string', description: 'Folder name', nullable: true }, + spaceName: { type: 'string', description: 'Space name', nullable: true }, + }, + }, +} + +export function extractClickUpErrorMessage( + response: Response, + data: unknown, + fallback: string +): string { + if (isRecordLike(data)) { + const message = + typeof data.err === 'string' && data.err.trim().length > 0 + ? data.err + : typeof data.error === 'string' && data.error.trim().length > 0 + ? data.error + : null + const ecode = data.ECODE + + if (message) { + return typeof ecode === 'string' && ecode.trim().length > 0 + ? `${fallback}: ${message} (${ecode})` + : `${fallback}: ${message}` + } + } + + if (response.statusText) { + return `${fallback}: ${response.status} ${response.statusText}` + } + + return `${fallback}: ${response.status}` +} diff --git a/apps/sim/tools/clickup/start_timer.ts b/apps/sim/tools/clickup/start_timer.ts new file mode 100644 index 00000000000..d3ab101c5b4 --- /dev/null +++ b/apps/sim/tools/clickup/start_timer.ts @@ -0,0 +1,111 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpStartTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupStartTimerTool: ToolConfig = + { + id: 'clickup_start_timer', + name: 'ClickUp Start Timer', + description: 'Start a timer for the authenticated user in a ClickUp workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) to start the timer in', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the timer with', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the time entry', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + tags: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Time entry tag names to apply', + items: { + type: 'string', + description: 'A time entry tag name', + }, + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/start`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.taskId) body.tid = params.taskId + if (params.description) body.description = params.description + if (params.billable !== undefined) body.billable = params.billable + if (params.tags?.length) body.tags = params.tags.map((name) => ({ name })) + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to start timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The started time entry (duration is negative while running)', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/clickup/stop_timer.ts b/apps/sim/tools/clickup/stop_timer.ts new file mode 100644 index 00000000000..752d3fe1f45 --- /dev/null +++ b/apps/sim/tools/clickup/stop_timer.ts @@ -0,0 +1,72 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTimeEntry, +} from '@/tools/clickup/shared' +import type { ClickUpStopTimerParams, ClickUpTimeEntryResponse } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupStopTimerTool: ToolConfig = { + id: 'clickup_stop_timer', + name: 'ClickUp Stop Timer', + description: "Stop the authenticated user's currently running timer in a ClickUp workspace", + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the timer is running in', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/stop`, + method: 'POST', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to stop timer') + return { success: false, output: { error }, error } + } + + const entry = isRecordLike(data) && isRecordLike(data.data) ? data.data : null + + return { + success: true, + output: { timeEntry: entry ? mapClickUpTimeEntry(entry) : null }, + } + }, + + outputs: { + timeEntry: { + type: 'json', + description: 'The stopped time entry', + optional: true, + properties: CLICKUP_TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/types.ts b/apps/sim/tools/clickup/types.ts new file mode 100644 index 00000000000..57b980cc2aa --- /dev/null +++ b/apps/sim/tools/clickup/types.ts @@ -0,0 +1,675 @@ +import type { UserFile } from '@/executor/types' +import type { ToolResponse } from '@/tools/types' + +export interface ClickUpUser { + id: number | null + username: string | null + email: string | null + profilePicture: string | null +} + +export interface ClickUpMember { + id: number | null + username: string | null + email: string | null + color: string | null + initials: string | null + profilePicture: string | null +} + +export interface ClickUpStatus { + status: string | null + color: string | null + type: string | null +} + +export interface ClickUpPriority { + id: string | null + priority: string | null + color: string | null +} + +export interface ClickUpTag { + name: string | null + tagFg: string | null + tagBg: string | null +} + +export interface ClickUpTask { + id: string + customId: string | null + name: string + textContent: string | null + description: string | null + markdownDescription: string | null + status: ClickUpStatus | null + archived: boolean + creator: ClickUpUser | null + assignees: ClickUpUser[] + watchers: ClickUpUser[] + tags: ClickUpTag[] + parent: string | null + priority: ClickUpPriority | null + dueDate: string | null + startDate: string | null + points: number | null + timeEstimate: number | null + timeSpent: number | null + customFields: Record[] + dateCreated: string | null + dateUpdated: string | null + dateClosed: string | null + dateDone: string | null + list: { id: string; name: string | null } | null + folder: { id: string; name: string | null } | null + space: { id: string; name: string | null } | null + url: string | null + subtasks: ClickUpTask[] | null +} + +export interface ClickUpComment { + id: string + commentText: string | null + resolved: boolean | null + user: ClickUpUser | null + assignee: ClickUpUser | null + date: string | null + replyCount: string | null +} + +export interface ClickUpWorkspace { + id: string + name: string | null + color: string | null + avatar: string | null +} + +export interface ClickUpSpace { + id: string + name: string | null + private: boolean | null + archived: boolean | null + statuses: ClickUpStatus[] +} + +export interface ClickUpFolder { + id: string + name: string | null + hidden: boolean | null + taskCount: string | null + space: { id: string; name: string | null } | null +} + +export interface ClickUpList { + id: string + name: string | null + taskCount: string | null + archived: boolean | null +} + +export interface ClickUpAttachment { + id: string + version: string | null + title: string | null + extension: string | null + url: string | null + date: number | null + thumbnailSmall: string | null + thumbnailLarge: string | null +} + +export interface ClickUpCustomField { + id: string + name: string | null + type: string | null + typeConfig: Record | null + dateCreated: string | null + hideFromGuests: boolean | null +} + +export interface ClickUpChecklistItem { + id: string + name: string | null + orderIndex: number | null + assignee: ClickUpUser | null + resolved: boolean | null + parent: string | null + dateCreated: string | null + children: string[] +} + +export interface ClickUpChecklist { + id: string + taskId: string | null + name: string | null + orderIndex: number | null + resolved: number | null + unresolved: number | null + dateCreated: string | null + items: ClickUpChecklistItem[] +} + +export interface ClickUpTimeEntryLocation { + listId: string | null + folderId: string | null + spaceId: string | null + listName: string | null + folderName: string | null + spaceName: string | null +} + +export interface ClickUpTimeEntry { + id: string + task: { id: string; name: string | null } | null + workspaceId: string | null + user: ClickUpUser | null + billable: boolean | null + start: string | null + end: string | null + duration: number | null + description: string | null + tags: ClickUpTag[] + source: string | null + at: string | null + taskUrl: string | null + taskTags: ClickUpTag[] + taskLocation: ClickUpTimeEntryLocation | null +} + +export interface ClickUpCreateTaskParams { + accessToken: string + listId: string + name: string + description?: string + markdownContent?: string + status?: string + priority?: number + dueDate?: number + dueDateTime?: boolean + startDate?: number + startDateTime?: boolean + assignees?: number[] + tags?: string[] + timeEstimate?: number + points?: number + parent?: string + notifyAll?: boolean +} + +export interface ClickUpTaskResponse extends ToolResponse { + output: { + task?: ClickUpTask + error?: string + } +} + +export interface ClickUpGetTaskParams { + accessToken: string + taskId: string + includeSubtasks?: boolean + includeMarkdownDescription?: boolean +} + +export interface ClickUpUpdateTaskParams { + accessToken: string + taskId: string + name?: string + description?: string + markdownContent?: string + status?: string + priority?: number + dueDate?: number + dueDateTime?: boolean + startDate?: number + startDateTime?: boolean + timeEstimate?: number + points?: number + parent?: string + archived?: boolean + assigneesToAdd?: number[] + assigneesToRemove?: number[] +} + +export interface ClickUpDeleteTaskParams { + accessToken: string + taskId: string +} + +export interface ClickUpDeleteResponse extends ToolResponse { + output: { + id?: string + deleted?: boolean + error?: string + } +} + +export interface ClickUpGetTasksParams { + accessToken: string + listId: string + page?: number + orderBy?: string + reverse?: boolean + subtasks?: boolean + includeClosed?: boolean + includeMarkdownDescription?: boolean + archived?: boolean + statuses?: string[] + assignees?: string[] + tags?: string[] + dueDateGt?: number + dueDateLt?: number +} + +export interface ClickUpTaskListResponse extends ToolResponse { + output: { + tasks?: ClickUpTask[] + error?: string + } +} + +export interface ClickUpSearchTasksParams { + accessToken: string + workspaceId: string + page?: number + orderBy?: string + reverse?: boolean + subtasks?: boolean + includeClosed?: boolean + includeMarkdownDescription?: boolean + listIds?: string[] + spaceIds?: string[] + folderIds?: string[] + statuses?: string[] + assignees?: string[] + tags?: string[] + dueDateGt?: number + dueDateLt?: number +} + +export interface ClickUpCreateCommentParams { + accessToken: string + taskId: string + commentText: string + assignee?: number + notifyAll?: boolean +} + +export interface ClickUpCreateCommentResponse extends ToolResponse { + output: { + id?: string + histId?: string + date?: number + error?: string + } +} + +export interface ClickUpGetCommentsParams { + accessToken: string + taskId: string + start?: number + startId?: string +} + +export interface ClickUpCommentListResponse extends ToolResponse { + output: { + comments?: ClickUpComment[] + error?: string + } +} + +export interface ClickUpUpdateCommentParams { + accessToken: string + commentId: string + commentText?: string + assignee?: number + resolved?: boolean +} + +export interface ClickUpUpdateCommentResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteCommentParams { + accessToken: string + commentId: string +} + +export interface ClickUpUploadAttachmentParams { + accessToken: string + taskId: string + file: UserFile | string +} + +export interface ClickUpUploadAttachmentResponse extends ToolResponse { + output: { + attachment?: ClickUpAttachment + files?: UserFile[] + error?: string + } +} + +export interface ClickUpGetWorkspacesParams { + accessToken: string +} + +export interface ClickUpWorkspaceListResponse extends ToolResponse { + output: { + workspaces?: ClickUpWorkspace[] + error?: string + } +} + +export interface ClickUpGetSpacesParams { + accessToken: string + workspaceId: string + archived?: boolean +} + +export interface ClickUpSpaceListResponse extends ToolResponse { + output: { + spaces?: ClickUpSpace[] + error?: string + } +} + +export interface ClickUpGetFoldersParams { + accessToken: string + spaceId: string + archived?: boolean +} + +export interface ClickUpFolderListResponse extends ToolResponse { + output: { + folders?: ClickUpFolder[] + error?: string + } +} + +export interface ClickUpCreateFolderParams { + accessToken: string + spaceId: string + name: string +} + +export interface ClickUpFolderResponse extends ToolResponse { + output: { + folder?: ClickUpFolder + error?: string + } +} + +export interface ClickUpGetListsParams { + accessToken: string + folderId?: string + spaceId?: string + archived?: boolean +} + +export interface ClickUpListListResponse extends ToolResponse { + output: { + lists?: ClickUpList[] + error?: string + } +} + +export interface ClickUpCreateListParams { + accessToken: string + folderId?: string + spaceId?: string + name: string + content?: string + markdownContent?: string +} + +export interface ClickUpListResponse extends ToolResponse { + output: { + list?: ClickUpList + error?: string + } +} + +export interface ClickUpGetSpaceTagsParams { + accessToken: string + spaceId: string +} + +export interface ClickUpTagListResponse extends ToolResponse { + output: { + tags?: ClickUpTag[] + error?: string + } +} + +export interface ClickUpTaskTagParams { + accessToken: string + taskId: string + tagName: string +} + +export interface ClickUpTaskTagResponse extends ToolResponse { + output: { + taskId?: string + tagName?: string + error?: string + } +} + +export interface ClickUpGetTaskMembersParams { + accessToken: string + taskId: string +} + +export interface ClickUpGetListMembersParams { + accessToken: string + listId: string +} + +export interface ClickUpMemberListResponse extends ToolResponse { + output: { + members?: ClickUpMember[] + error?: string + } +} + +export interface ClickUpGetCustomFieldsParams { + accessToken: string + listId: string +} + +export interface ClickUpCustomFieldListResponse extends ToolResponse { + output: { + fields?: ClickUpCustomField[] + error?: string + } +} + +export interface ClickUpSetCustomFieldValueParams { + accessToken: string + taskId: string + fieldId: string + value: unknown +} + +export interface ClickUpCustomFieldValueResponse extends ToolResponse { + output: { + taskId?: string + fieldId?: string + error?: string + } +} + +export interface ClickUpRemoveCustomFieldValueParams { + accessToken: string + taskId: string + fieldId: string +} + +export interface ClickUpCreateChecklistParams { + accessToken: string + taskId: string + name: string +} + +export interface ClickUpChecklistResponse extends ToolResponse { + output: { + checklist?: ClickUpChecklist + error?: string + } +} + +export interface ClickUpUpdateChecklistParams { + accessToken: string + checklistId: string + name?: string + position?: number +} + +export interface ClickUpUpdateChecklistResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteChecklistParams { + accessToken: string + checklistId: string +} + +export interface ClickUpCreateChecklistItemParams { + accessToken: string + checklistId: string + name: string + assignee?: number +} + +export interface ClickUpUpdateChecklistItemParams { + accessToken: string + checklistId: string + checklistItemId: string + name?: string + assignee?: number + resolved?: boolean + parent?: string +} + +export interface ClickUpDeleteChecklistItemParams { + accessToken: string + checklistId: string + checklistItemId: string +} + +export interface ClickUpGetTimeEntriesParams { + accessToken: string + workspaceId: string + startDate?: number + endDate?: number + assignee?: string + taskId?: string + listId?: string + folderId?: string + spaceId?: string + includeTaskTags?: boolean + includeLocationNames?: boolean +} + +export interface ClickUpTimeEntryListResponse extends ToolResponse { + output: { + timeEntries?: ClickUpTimeEntry[] + error?: string + } +} + +export interface ClickUpCreateTimeEntryParams { + accessToken: string + workspaceId: string + start: number + duration: number + description?: string + billable?: boolean + taskId?: string + assignee?: number +} + +export interface ClickUpTimeEntryResponse extends ToolResponse { + output: { + timeEntry?: ClickUpTimeEntry | null + error?: string + } +} + +export interface ClickUpUpdateTimeEntryParams { + accessToken: string + workspaceId: string + timerId: string + description?: string + start?: number + end?: number + duration?: number + taskId?: string + billable?: boolean +} + +export interface ClickUpUpdateTimeEntryResponse extends ToolResponse { + output: { + id?: string + updated?: boolean + error?: string + } +} + +export interface ClickUpDeleteTimeEntryParams { + accessToken: string + workspaceId: string + timerId: string +} + +export interface ClickUpStartTimerParams { + accessToken: string + workspaceId: string + taskId?: string + description?: string + billable?: boolean + tags?: string[] +} + +export interface ClickUpStopTimerParams { + accessToken: string + workspaceId: string +} + +export interface ClickUpGetRunningTimerParams { + accessToken: string + workspaceId: string + assignee?: number +} + +export type ClickUpResponse = + | ClickUpTaskResponse + | ClickUpTaskListResponse + | ClickUpDeleteResponse + | ClickUpCreateCommentResponse + | ClickUpCommentListResponse + | ClickUpUpdateCommentResponse + | ClickUpUploadAttachmentResponse + | ClickUpWorkspaceListResponse + | ClickUpSpaceListResponse + | ClickUpFolderListResponse + | ClickUpFolderResponse + | ClickUpListListResponse + | ClickUpListResponse + | ClickUpTagListResponse + | ClickUpTaskTagResponse + | ClickUpMemberListResponse + | ClickUpCustomFieldListResponse + | ClickUpCustomFieldValueResponse + | ClickUpChecklistResponse + | ClickUpUpdateChecklistResponse + | ClickUpTimeEntryListResponse + | ClickUpTimeEntryResponse + | ClickUpUpdateTimeEntryResponse diff --git a/apps/sim/tools/clickup/update_checklist.ts b/apps/sim/tools/clickup/update_checklist.ts new file mode 100644 index 00000000000..d135daa6d65 --- /dev/null +++ b/apps/sim/tools/clickup/update_checklist.ts @@ -0,0 +1,91 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateChecklistParams, + ClickUpUpdateChecklistResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateChecklistTool: ToolConfig< + ClickUpUpdateChecklistParams, + ClickUpUpdateChecklistResponse +> = { + id: 'clickup_update_checklist', + name: 'ClickUp Update Checklist', + description: 'Rename or reorder a checklist on a ClickUp task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist', + }, + position: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New position of the checklist on the task (0 places it first)', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.position !== undefined) body.position = params.position + + if (Object.keys(body).length === 0) { + throw new Error('At least one of name or position is required to update a checklist') + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update checklist') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.checklistId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated checklist', optional: true }, + updated: { type: 'boolean', description: 'Whether the checklist was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/update_checklist_item.ts b/apps/sim/tools/clickup/update_checklist_item.ts new file mode 100644 index 00000000000..4008f0433b3 --- /dev/null +++ b/apps/sim/tools/clickup/update_checklist_item.ts @@ -0,0 +1,122 @@ +import { isRecordLike } from '@sim/utils/object' +import { + CLICKUP_API_BASE_URL, + CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpChecklist, +} from '@/tools/clickup/shared' +import type { + ClickUpChecklistResponse, + ClickUpUpdateChecklistItemParams, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateChecklistItemTool: ToolConfig< + ClickUpUpdateChecklistItemParams, + ClickUpChecklistResponse +> = { + id: 'clickup_update_checklist_item', + name: 'ClickUp Update Checklist Item', + description: 'Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist containing the item', + }, + checklistItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UUID of the checklist item to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist item', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the item to', + }, + resolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the item is resolved', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of another checklist item to nest this item under', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/checklist/${encodeURIComponent(params.checklistId)}/checklist_item/${encodeURIComponent(params.checklistItemId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.assignee !== undefined) body.assignee = params.assignee + if (params.resolved !== undefined) body.resolved = params.resolved + if (params.parent !== undefined) body.parent = params.parent + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of name, assignee, resolved, or parent is required to update a checklist item' + ) + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to update checklist item') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { checklist: mapClickUpChecklist(isRecordLike(data) ? data.checklist : null) }, + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'The updated checklist including its items', + optional: true, + properties: CLICKUP_CHECKLIST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/update_comment.ts b/apps/sim/tools/clickup/update_comment.ts new file mode 100644 index 00000000000..e20f82d3f5d --- /dev/null +++ b/apps/sim/tools/clickup/update_comment.ts @@ -0,0 +1,100 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateCommentParams, + ClickUpUpdateCommentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateCommentTool: ToolConfig< + ClickUpUpdateCommentParams, + ClickUpUpdateCommentResponse +> = { + id: 'clickup_update_comment', + name: 'ClickUp Update Comment', + description: 'Update the content, assignee, or resolved state of a ClickUp task comment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the comment to update', + }, + commentText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New content for the comment', + }, + assignee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID to assign the comment to', + }, + resolved: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the comment is resolved', + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/comment/${encodeURIComponent(params.commentId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.commentText !== undefined) body.comment_text = params.commentText + if (params.assignee !== undefined) body.assignee = params.assignee + if (params.resolved !== undefined) body.resolved = params.resolved + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of commentText, assignee, or resolved is required to update a comment' + ) + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update comment') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.commentId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated comment', optional: true }, + updated: { type: 'boolean', description: 'Whether the comment was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/update_task.ts b/apps/sim/tools/clickup/update_task.ts new file mode 100644 index 00000000000..a66d7356ad0 --- /dev/null +++ b/apps/sim/tools/clickup/update_task.ts @@ -0,0 +1,195 @@ +import { + CLICKUP_API_BASE_URL, + CLICKUP_TASK_OUTPUT_PROPERTIES, + clickupAuthorizationHeader, + extractClickUpErrorMessage, + mapClickUpTask, +} from '@/tools/clickup/shared' +import type { ClickUpTaskResponse, ClickUpUpdateTaskParams } from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateTaskTool: ToolConfig = { + id: 'clickup_update_task', + name: 'ClickUp Update Task', + description: 'Update an existing task in ClickUp', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the task', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New plain text description (use a single space to clear)', + }, + markdownContent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New Markdown description (takes precedence over description)', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New status for the task (must exist in the list)', + }, + priority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority: 1 (urgent), 2 (high), 3 (normal), 4 (low)', + }, + dueDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New due date as a Unix timestamp in milliseconds', + }, + dueDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the due date includes a time of day', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New start date as a Unix timestamp in milliseconds', + }, + startDateTime: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the start date includes a time of day', + }, + timeEstimate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New time estimate in milliseconds', + }, + points: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New sprint points value', + }, + parent: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Parent task ID to move this task under (cannot be cleared)', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Set to true to archive the task, false to unarchive', + }, + assigneesToAdd: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to add as assignees', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + assigneesToRemove: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs to remove from assignees', + items: { + type: 'number', + description: 'A ClickUp user ID', + }, + }, + }, + + request: { + url: (params) => `${CLICKUP_API_BASE_URL}/task/${encodeURIComponent(params.taskId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.description !== undefined) body.description = params.description + if (params.markdownContent !== undefined) body.markdown_content = params.markdownContent + if (params.status !== undefined) body.status = params.status + if (params.priority !== undefined) body.priority = params.priority + if (params.dueDate !== undefined) body.due_date = params.dueDate + if (params.dueDateTime !== undefined) body.due_date_time = params.dueDateTime + if (params.startDate !== undefined) body.start_date = params.startDate + if (params.startDateTime !== undefined) body.start_date_time = params.startDateTime + if (params.timeEstimate !== undefined) body.time_estimate = params.timeEstimate + if (params.points !== undefined) body.points = params.points + if (params.parent !== undefined) body.parent = params.parent + if (params.archived !== undefined) body.archived = params.archived + if (params.assigneesToAdd?.length || params.assigneesToRemove?.length) { + body.assignees = { + add: params.assigneesToAdd ?? [], + rem: params.assigneesToRemove ?? [], + } + } + + if (Object.keys(body).length === 0) { + throw new Error('At least one field to update is required to update a task') + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractClickUpErrorMessage(response, data, 'Failed to update task') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { task: mapClickUpTask(data) }, + } + }, + + outputs: { + task: { + type: 'json', + description: 'The updated task', + optional: true, + properties: CLICKUP_TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/clickup/update_time_entry.ts b/apps/sim/tools/clickup/update_time_entry.ts new file mode 100644 index 00000000000..1744e982f0a --- /dev/null +++ b/apps/sim/tools/clickup/update_time_entry.ts @@ -0,0 +1,133 @@ +import { + CLICKUP_API_BASE_URL, + clickupAuthorizationHeader, + extractClickUpErrorMessage, +} from '@/tools/clickup/shared' +import type { + ClickUpUpdateTimeEntryParams, + ClickUpUpdateTimeEntryResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUpdateTimeEntryTool: ToolConfig< + ClickUpUpdateTimeEntryParams, + ClickUpUpdateTimeEntryResponse +> = { + id: 'clickup_update_time_entry', + name: 'ClickUp Update Time Entry', + description: + 'Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + workspaceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workspace (team) the entry belongs to', + }, + timerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to update', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New description for the entry', + }, + start: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New start (Unix ms); when provided, end must also be provided', + }, + end: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New end (Unix ms); when provided, start must also be provided', + }, + duration: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New duration in milliseconds', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Task ID to associate the entry with', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the entry is billable', + }, + }, + + request: { + url: (params) => + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(params.workspaceId)}/time_entries/${encodeURIComponent(params.timerId)}`, + method: 'PUT', + headers: (params) => ({ + Authorization: clickupAuthorizationHeader(params.accessToken), + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.description !== undefined) body.description = params.description + if (params.start !== undefined) body.start = params.start + if (params.end !== undefined) body.end = params.end + if (params.duration !== undefined) body.duration = params.duration + if (params.taskId) body.tid = params.taskId + if (params.billable !== undefined) body.billable = params.billable + + if (Object.keys(body).length === 0) { + throw new Error( + 'At least one of description, start and end, duration, taskId, or billable is required to update a time entry' + ) + } + + if ((params.start === undefined) !== (params.end === undefined)) { + throw new Error('start and end must be provided together when updating entry times') + } + + return body + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const data = await response.json().catch(() => null) + const error = extractClickUpErrorMessage(response, data, 'Failed to update time entry') + return { success: false, output: { error }, error } + } + + return { + success: true, + output: { id: params?.timerId, updated: true }, + } + }, + + outputs: { + id: { type: 'string', description: 'ID of the updated time entry', optional: true }, + updated: { type: 'boolean', description: 'Whether the entry was updated', optional: true }, + }, +} diff --git a/apps/sim/tools/clickup/upload_attachment.ts b/apps/sim/tools/clickup/upload_attachment.ts new file mode 100644 index 00000000000..4518977516e --- /dev/null +++ b/apps/sim/tools/clickup/upload_attachment.ts @@ -0,0 +1,89 @@ +import type { + ClickUpUploadAttachmentParams, + ClickUpUploadAttachmentResponse, +} from '@/tools/clickup/types' +import type { ToolConfig } from '@/tools/types' + +export const clickupUploadAttachmentTool: ToolConfig< + ClickUpUploadAttachmentParams, + ClickUpUploadAttachmentResponse +> = { + id: 'clickup_upload_attachment', + name: 'ClickUp Upload Attachment', + description: 'Upload a file to a ClickUp task as an attachment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'clickup', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token or personal API token for ClickUp', + }, + taskId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the task to attach the file to', + }, + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'File to attach to the task', + }, + }, + + request: { + url: '/api/tools/clickup/upload-attachment', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + taskId: params.taskId, + file: params.file, + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || 'Failed to upload ClickUp attachment') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + attachment: { + type: 'json', + description: 'The created attachment', + optional: true, + properties: { + id: { type: 'string', description: 'Attachment ID' }, + version: { type: 'string', description: 'Attachment version', nullable: true }, + title: { type: 'string', description: 'Attachment title', nullable: true }, + extension: { type: 'string', description: 'File extension', nullable: true }, + url: { type: 'string', description: 'URL of the uploaded attachment', nullable: true }, + date: { + type: 'number', + description: 'Upload timestamp (Unix ms)', + nullable: true, + }, + thumbnailSmall: { type: 'string', description: 'Small thumbnail URL', nullable: true }, + thumbnailLarge: { type: 'string', description: 'Large thumbnail URL', nullable: true }, + }, + }, + files: { type: 'file[]', description: 'The uploaded attachment file' }, + }, +} diff --git a/apps/sim/tools/gitlab/access.test.ts b/apps/sim/tools/gitlab/access.test.ts new file mode 100644 index 00000000000..65820f6f152 --- /dev/null +++ b/apps/sim/tools/gitlab/access.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { gitlabAddMemberTool } from '@/tools/gitlab/add_member' +import { gitlabApproveAccessRequestTool } from '@/tools/gitlab/approve_access_request' +import { gitlabInviteMemberTool } from '@/tools/gitlab/invite_member' +import { gitlabListMembersTool } from '@/tools/gitlab/list_members' +import { gitlabUpdateMemberTool } from '@/tools/gitlab/update_member' +import { gitlabBlockUserTool } from '@/tools/gitlab/user_status_actions' + +interface MockResponseOptions { + ok?: boolean + status?: number + json?: unknown + text?: string + headers?: Record +} + +function mockResponse({ + ok = true, + status = 200, + json, + text = '', + headers = {}, +}: MockResponseOptions): Response { + return { + ok, + status, + json: async () => json, + text: async () => text, + headers: { + get: (key: string) => headers[key.toLowerCase()] ?? null, + }, + } as unknown as Response +} + +const baseArgs = { accessToken: 'pat', resourceType: 'group' as const, resourceId: '42' } + +describe('gitlab_list_members', () => { + it('defaults to /members/all so inherited members are included', () => { + const url = gitlabListMembersTool.request.url({ ...baseArgs }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/members/all') + }) + + it('uses /members when directOnly is set', () => { + const url = gitlabListMembersTool.request.url({ ...baseArgs, directOnly: true }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/members') + }) + + it('builds a project path and forwards pagination', () => { + const url = gitlabListMembersTool.request.url({ + ...baseArgs, + resourceType: 'project', + resourceId: 'grp/proj', + perPage: 50, + page: 2, + }) + expect(url).toBe('https://gitlab.com/api/v4/projects/grp%2Fproj/members/all?per_page=50&page=2') + }) +}) + +describe('gitlab_add_member', () => { + it('sends integer user_id and access_level in the body', () => { + const body = gitlabAddMemberTool.request.body?.({ + ...baseArgs, + userId: 7, + accessLevel: 30, + expiresAt: '2026-12-31', + memberRoleId: 5, + }) + expect(body).toEqual({ + user_id: 7, + access_level: 30, + expires_at: '2026-12-31', + member_role_id: 5, + }) + }) + + it('treats a 409 as a soft success so workflows are re-runnable', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ + ok: false, + status: 409, + json: { message: 'Member already exists' }, + text: '{"message":"Member already exists"}', + }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.alreadyMember).toBe(true) + }) + + it('returns the created member on success', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { id: 7, access_level: 30 } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.alreadyMember).toBe(false) + expect(result.output.member).toEqual({ id: 7, access_level: 30 }) + }) + + it('surfaces a 409 that is not an already-member conflict as a failure', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: false, status: 409, text: '{"message":"Seat limit reached"}' }), + {} as never + ) + expect(result.success).toBe(false) + }) + + it('surfaces other errors as hard failures', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: false, status: 403, text: 'Forbidden' }), + {} as never + ) + expect(result.success).toBe(false) + }) +}) + +describe('gitlab_update_member', () => { + it('sends the new access_level integer and expires_at', () => { + const body = gitlabUpdateMemberTool.request.body?.({ + ...baseArgs, + userId: 7, + accessLevel: 40, + expiresAt: '2027-01-01', + }) + expect(body).toEqual({ access_level: 40, expires_at: '2027-01-01' }) + }) +}) + +describe('gitlab_approve_access_request', () => { + it('passes the granted access_level as an integer query param', () => { + const url = gitlabApproveAccessRequestTool.request.url({ + ...baseArgs, + userId: 7, + accessLevel: 40, + }) + expect(url).toBe( + 'https://gitlab.com/api/v4/groups/42/access_requests/7/approve?access_level=40' + ) + }) + + it('omits access_level when not provided (GitLab defaults to Developer)', () => { + const url = gitlabApproveAccessRequestTool.request.url({ ...baseArgs, userId: 7 }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/access_requests/7/approve') + }) +}) + +describe('gitlab_invite_member', () => { + it('reports a per-email failure even when GitLab returns 200 with status:error', async () => { + const result = await gitlabInviteMemberTool.transformResponse!( + mockResponse({ + ok: true, + status: 201, + json: { status: 'error', message: { 'a@b.com': 'Already invited' } }, + }), + {} as never + ) + expect(result.success).toBe(false) + expect(result.output.status).toBe('error') + }) + + it('reports success when GitLab accepts the invite', async () => { + const result = await gitlabInviteMemberTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { status: 'success' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.status).toBe('success') + }) +}) + +describe('gitlab_invite_member email normalization', () => { + it('normalizes a comma-separated list with spaces into GitLab-accepted form', () => { + const body = gitlabInviteMemberTool.request.body?.({ + ...baseArgs, + email: 'alice@example.com, bob@example.com', + accessLevel: 30, + }) as Record + expect(body.email).toBe('alice@example.com,bob@example.com') + }) + + it('passes a single email through unchanged', () => { + const body = gitlabInviteMemberTool.request.body?.({ + ...baseArgs, + email: 'alice@example.com', + accessLevel: 30, + }) as Record + expect(body.email).toBe('alice@example.com') + }) +}) + +describe('gitlab user status actions', () => { + it('returns success with no user object when GitLab responds with a bare true', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: true }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.success).toBe(true) + expect(result.output.user).toBeUndefined() + }) + + it('surfaces the updated user object when GitLab returns one', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { id: 9, state: 'blocked' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.user).toEqual({ id: 9, state: 'blocked' }) + }) +}) diff --git a/apps/sim/tools/gitlab/add_member.ts b/apps/sim/tools/gitlab/add_member.ts new file mode 100644 index 00000000000..10b0ebe9dbd --- /dev/null +++ b/apps/sim/tools/gitlab/add_member.ts @@ -0,0 +1,146 @@ +import type { GitLabAddMemberParams, GitLabAddMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabAddMemberTool: ToolConfig = { + id: 'gitlab_add_member', + name: 'GitLab Add Member', + description: 'Add an existing GitLab user to a project or group at a given access level', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + userId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'The ID of the user to add. Provide either userId or username.', + }, + username: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The username of the user to add. Provide either userId or username.', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level: 0 (No access), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/members` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + access_level: params.accessLevel, + } + + // GitLab accepts either user_id or username to identify the user. + if (params.userId !== undefined && params.userId !== null) body.user_id = params.userId + else if (params.username?.trim()) body.username = params.username.trim() + + if (params.expiresAt) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + // A 409 with "already exists" means the user is already a member. Treat it + // as a soft success so provisioning workflows remain safely re-runnable — + // but only for that specific conflict, so other 409s still surface. + if (response.status === 409) { + const conflictText = await response.text() + if (/already exists|already a member/i.test(conflictText)) { + return { + success: true, + output: { + alreadyMember: true, + }, + } + } + return { + success: false, + error: `GitLab API error: 409 ${conflictText}`, + output: {}, + } + } + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const member = await response.json() + + return { + success: true, + output: { + member, + alreadyMember: false, + }, + } + }, + + outputs: { + member: { + type: 'object', + description: 'The added member', + }, + alreadyMember: { + type: 'boolean', + description: 'Whether the user was already a member (add was a no-op)', + }, + }, +} diff --git a/apps/sim/tools/gitlab/add_saml_group_link.ts b/apps/sim/tools/gitlab/add_saml_group_link.ts new file mode 100644 index 00000000000..17b833853c7 --- /dev/null +++ b/apps/sim/tools/gitlab/add_saml_group_link.ts @@ -0,0 +1,114 @@ +import type { + GitLabAddSamlGroupLinkParams, + GitLabSamlGroupLinkResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabAddSamlGroupLinkTool: ToolConfig< + GitLabAddSamlGroupLinkParams, + GitLabSamlGroupLinkResponse +> = { + id: 'gitlab_add_saml_group_link', + name: 'GitLab Add SAML Group Link', + description: + 'Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level (GitLab Premium/Ultimate)', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token with group Owner rights', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or path (e.g. my-org/my-group)', + }, + samlGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the SAML group as sent by the identity provider', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level granted to members of the SAML group: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + provider: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Unique provider name that must match for this group link to be applied (GitLab 18.2+)', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + saml_group_name: params.samlGroupName, + access_level: params.accessLevel, + } + + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + if (params.provider) body.provider = params.provider.trim() + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const samlGroupLink = await response.json() + + return { + success: true, + output: { + samlGroupLink, + }, + } + }, + + outputs: { + samlGroupLink: { + type: 'object', + description: 'The created SAML group link', + }, + }, +} diff --git a/apps/sim/tools/gitlab/approve_access_request.ts b/apps/sim/tools/gitlab/approve_access_request.ts new file mode 100644 index 00000000000..8bdfeb3c3b6 --- /dev/null +++ b/apps/sim/tools/gitlab/approve_access_request.ts @@ -0,0 +1,101 @@ +import type { + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabApproveAccessRequestTool: ToolConfig< + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse +> = { + id: 'gitlab_approve_access_request', + name: 'GitLab Approve Access Request', + description: 'Approve a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The user ID of the access requester', + }, + accessLevel: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Access level to grant: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner). Defaults to 30 (Developer).', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.accessLevel !== undefined) { + queryParams.append('access_level', String(params.accessLevel)) + } + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests/${params.userId}/approve${query ? `?${query}` : ''}` + }, + method: 'PUT', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const accessRequest = await response.json() + + return { + success: true, + output: { + accessRequest, + }, + } + }, + + outputs: { + accessRequest: { + type: 'object', + description: 'The approved access request', + }, + }, +} diff --git a/apps/sim/tools/gitlab/approve_merge_request.ts b/apps/sim/tools/gitlab/approve_merge_request.ts index 0db63c53539..c818b130e27 100644 --- a/apps/sim/tools/gitlab/approve_merge_request.ts +++ b/apps/sim/tools/gitlab/approve_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabApproveMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', diff --git a/apps/sim/tools/gitlab/cancel_pipeline.ts b/apps/sim/tools/gitlab/cancel_pipeline.ts index b16ef0534e9..005a0538ed0 100644 --- a/apps/sim/tools/gitlab/cancel_pipeline.ts +++ b/apps/sim/tools/gitlab/cancel_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCancelPipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, pipelineId: { type: 'number', diff --git a/apps/sim/tools/gitlab/compare_branches.ts b/apps/sim/tools/gitlab/compare_branches.ts index ae6df3deedd..f45e02a3042 100644 --- a/apps/sim/tools/gitlab/compare_branches.ts +++ b/apps/sim/tools/gitlab/compare_branches.ts @@ -31,7 +31,7 @@ export const gitlabCompareBranchesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, from: { type: 'string', @@ -51,6 +51,18 @@ export const gitlabCompareBranchesTool: ToolConfig< visibility: 'user-or-llm', description: 'Compare directly from..to instead of using the merge base (defaults to false)', }, + fromProjectId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the project to compare from (for cross-fork comparisons)', + }, + unidiff: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return diffs in unified diff format (GitLab 16.5+)', + }, }, request: { @@ -60,6 +72,10 @@ export const gitlabCompareBranchesTool: ToolConfig< queryParams.append('from', params.from) queryParams.append('to', params.to) if (params.straight) queryParams.append('straight', 'true') + if (params.fromProjectId) { + queryParams.append('from_project_id', String(params.fromProjectId).trim()) + } + if (params.unidiff) queryParams.append('unidiff', 'true') return `${getGitLabApiBase(params.host)}/projects/${encodedId}/repository/compare?${queryParams.toString()}` }, diff --git a/apps/sim/tools/gitlab/create_branch.ts b/apps/sim/tools/gitlab/create_branch.ts index 8fe6a8697c2..69e3b6dfe24 100644 --- a/apps/sim/tools/gitlab/create_branch.ts +++ b/apps/sim/tools/gitlab/create_branch.ts @@ -28,7 +28,7 @@ export const gitlabCreateBranchTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, branch: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_file.ts b/apps/sim/tools/gitlab/create_file.ts index f0468959b4a..7aa82c5de7a 100644 --- a/apps/sim/tools/gitlab/create_file.ts +++ b/apps/sim/tools/gitlab/create_file.ts @@ -25,7 +25,7 @@ export const gitlabCreateFileTool: ToolConfig ({ - branch: params.branch, - content: params.content, - commit_message: params.commitMessage, - encoding: 'text', - }), + body: (params) => { + const body: Record = { + branch: params.branch, + content: params.content, + commit_message: params.commitMessage, + encoding: 'text', + } + + if (params.startBranch) body.start_branch = params.startBranch + if (params.authorName) body.author_name = params.authorName + if (params.authorEmail) body.author_email = params.authorEmail + if (params.executeFilemode !== undefined) body.execute_filemode = params.executeFilemode + + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_issue.ts b/apps/sim/tools/gitlab/create_issue.ts index f3830a81f0a..0e6d51e702c 100644 --- a/apps/sim/tools/gitlab/create_issue.ts +++ b/apps/sim/tools/gitlab/create_issue.ts @@ -26,7 +26,7 @@ export const gitlabCreateIssueTool: ToolConfig ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_merge_request.ts b/apps/sim/tools/gitlab/create_merge_request.ts index 9c47556553e..f0ebff56a36 100644 --- a/apps/sim/tools/gitlab/create_merge_request.ts +++ b/apps/sim/tools/gitlab/create_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabCreateMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, sourceBranch: { type: 'string', @@ -91,7 +91,7 @@ export const gitlabCreateMergeRequestTool: ToolConfig< type: 'boolean', required: false, visibility: 'user-or-llm', - description: 'Mark as draft (work in progress)', + description: 'Mark as draft (applied via the "Draft:" title prefix)', }, }, @@ -106,10 +106,16 @@ export const gitlabCreateMergeRequestTool: ToolConfig< 'PRIVATE-TOKEN': params.accessToken, }), body: (params) => { + // GitLab has no `draft` body param — draft status is conveyed via a + // "Draft:" title prefix, so apply the prefix when requested. + const title = + params.draft === true && !/^\s*draft:/i.test(params.title) + ? `Draft: ${params.title}` + : params.title const body: Record = { source_branch: params.sourceBranch, target_branch: params.targetBranch, - title: params.title, + title, } if (params.description) body.description = params.description @@ -120,7 +126,6 @@ export const gitlabCreateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/create_merge_request_note.ts b/apps/sim/tools/gitlab/create_merge_request_note.ts index 7f364efba51..09fc9fcffce 100644 --- a/apps/sim/tools/gitlab/create_merge_request_note.ts +++ b/apps/sim/tools/gitlab/create_merge_request_note.ts @@ -31,7 +31,7 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', @@ -45,6 +45,12 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< visibility: 'user-or-llm', description: 'Comment body (Markdown supported)', }, + internal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the comment as an internal note visible only to project members', + }, }, request: { @@ -57,9 +63,11 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), - body: (params) => ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_pipeline.ts b/apps/sim/tools/gitlab/create_pipeline.ts index 6a9777ea53b..5e238807ffe 100644 --- a/apps/sim/tools/gitlab/create_pipeline.ts +++ b/apps/sim/tools/gitlab/create_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCreatePipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, ref: { type: 'string', @@ -43,6 +43,12 @@ export const gitlabCreatePipelineTool: ToolConfig< description: 'Array of variables for the pipeline (each with key, value, and optional variable_type)', }, + inputs: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Pipeline inputs as a key/value object (for pipelines with spec:inputs)', + }, }, request: { @@ -63,6 +69,9 @@ export const gitlabCreatePipelineTool: ToolConfig< if (params.variables && params.variables.length > 0) { body.variables = params.variables } + if (params.inputs && Object.keys(params.inputs).length > 0) { + body.inputs = params.inputs + } return body }, diff --git a/apps/sim/tools/gitlab/create_release.ts b/apps/sim/tools/gitlab/create_release.ts index 38d15f27e86..776f30055b7 100644 --- a/apps/sim/tools/gitlab/create_release.ts +++ b/apps/sim/tools/gitlab/create_release.ts @@ -28,7 +28,7 @@ export const gitlabCreateReleaseTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, tagName: { type: 'string', @@ -60,6 +60,19 @@ export const gitlabCreateReleaseTool: ToolConfig< visibility: 'user-or-llm', description: 'ISO 8601 date for an upcoming or historical release', }, + tagMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Annotation message to use if creating a new annotated tag', + }, + assetLinks: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Release asset links: array of objects with name, url, and optional link_type (other, runbook, image, package)', + }, milestones: { type: 'array', required: false, @@ -83,6 +96,13 @@ export const gitlabCreateReleaseTool: ToolConfig< tag_name: params.tagName, } + if (params.tagMessage) body.tag_message = params.tagMessage + if (params.assetLinks) { + // Tolerate a single link object by wrapping it into the array GitLab expects. + const links = Array.isArray(params.assetLinks) ? params.assetLinks : [params.assetLinks] + if (links.length > 0) body.assets = { links } + } + if (params.name) body.name = params.name if (params.description) body.description = params.description if (params.ref) body.ref = params.ref diff --git a/apps/sim/tools/gitlab/create_user.ts b/apps/sim/tools/gitlab/create_user.ts new file mode 100644 index 00000000000..da6de99992b --- /dev/null +++ b/apps/sim/tools/gitlab/create_user.ts @@ -0,0 +1,127 @@ +import type { GitLabCreateUserParams, GitLabUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabCreateUserTool: ToolConfig = { + id: 'gitlab_create_user', + name: 'GitLab Create User', + description: + 'Create a new GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's email address", + }, + username: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's username", + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's display name", + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: "The user's password. Omit and set resetPassword to email a reset link instead.", + }, + resetPassword: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Send the user a password reset link instead of setting a password', + }, + forceRandomPassword: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Set a random password without emailing a reset link (useful for SSO-only accounts). One of password, resetPassword, or forceRandomPassword is required.', + }, + admin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the new user is an administrator', + }, + skipConfirmation: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Skip email confirmation for the new user', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + email: params.email, + username: params.username, + name: params.name, + } + + if (params.password) body.password = params.password + if (params.resetPassword !== undefined) body.reset_password = params.resetPassword + if (params.forceRandomPassword !== undefined) + body.force_random_password = params.forceRandomPassword + if (params.admin !== undefined) body.admin = params.admin + if (params.skipConfirmation !== undefined) body.skip_confirmation = params.skipConfirmation + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const user = await response.json() + + return { + success: true, + output: { + user, + }, + } + }, + + outputs: { + user: { + type: 'object', + description: 'The created user', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_branch.ts b/apps/sim/tools/gitlab/delete_branch.ts index 2570b90bdbc..560e15bb39c 100644 --- a/apps/sim/tools/gitlab/delete_branch.ts +++ b/apps/sim/tools/gitlab/delete_branch.ts @@ -28,7 +28,7 @@ export const gitlabDeleteBranchTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, branch: { type: 'string', diff --git a/apps/sim/tools/gitlab/delete_issue.ts b/apps/sim/tools/gitlab/delete_issue.ts index 7521b826629..9b0687e6754 100644 --- a/apps/sim/tools/gitlab/delete_issue.ts +++ b/apps/sim/tools/gitlab/delete_issue.ts @@ -26,7 +26,7 @@ export const gitlabDeleteIssueTool: ToolConfig = { + id: 'gitlab_delete_saml_group_link', + name: 'GitLab Delete SAML Group Link', + description: 'Delete a SAML group link from a GitLab group (GitLab Premium/Ultimate)', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token with group Owner rights', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or path (e.g. my-org/my-group)', + }, + samlGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the SAML group link to delete', + }, + provider: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Provider name of the link to delete. Required when multiple links share the same SAML group name.', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + const encodedName = encodeURIComponent(String(params.samlGroupName).trim()) + const queryParams = new URLSearchParams() + if (params.provider) queryParams.append('provider', params.provider.trim()) + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links/${encodedName}${query ? `?${query}` : ''}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the SAML group link was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user.ts b/apps/sim/tools/gitlab/delete_user.ts new file mode 100644 index 00000000000..7f7ee452189 --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user.ts @@ -0,0 +1,79 @@ +import type { GitLabDeleteUserParams, GitLabDeleteUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserTool: ToolConfig = { + id: 'gitlab_delete_user', + name: 'GitLab Delete User', + description: + 'Delete a GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to delete', + }, + hardDelete: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, contributions, personal projects, AND groups owned solely by this user are deleted rather than moved to a Ghost User', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.hardDelete !== undefined) { + queryParams.append('hard_delete', String(params.hardDelete)) + } + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/users/${params.userId}${query ? `?${query}` : ''}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the user was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user_identity.ts b/apps/sim/tools/gitlab/delete_user_identity.ts new file mode 100644 index 00000000000..2e0087ac987 --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user_identity.ts @@ -0,0 +1,80 @@ +import type { + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserIdentityTool: ToolConfig< + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse +> = { + id: 'gitlab_delete_user_identity', + name: 'GitLab Delete User Identity', + description: + "Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance.", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user', + }, + provider: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The external identity provider name (e.g. saml, ldapmain)', + }, + }, + + request: { + url: (params) => { + const encodedProvider = encodeURIComponent(String(params.provider).trim()) + return `${getGitLabApiBase(params.host)}/users/${params.userId}/identities/${encodedProvider}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the identity was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/deny_access_request.ts b/apps/sim/tools/gitlab/deny_access_request.ts new file mode 100644 index 00000000000..a194f0869bd --- /dev/null +++ b/apps/sim/tools/gitlab/deny_access_request.ts @@ -0,0 +1,85 @@ +import type { + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDenyAccessRequestTool: ToolConfig< + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse +> = { + id: 'gitlab_deny_access_request', + name: 'GitLab Deny Access Request', + description: 'Deny (delete) a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The user ID of the access requester', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests/${params.userId}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the access request was denied successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/get_file.ts b/apps/sim/tools/gitlab/get_file.ts index 7c394b2591f..bf4220b61ca 100644 --- a/apps/sim/tools/gitlab/get_file.ts +++ b/apps/sim/tools/gitlab/get_file.ts @@ -25,7 +25,7 @@ export const gitlabGetFileTool: ToolConfig maxContentChars return { success: true, @@ -76,7 +80,8 @@ export const gitlabGetFileTool: ToolConfig = { id: 'gitlab_get_job_log', name: 'GitLab Get Job Log', @@ -25,7 +33,7 @@ export const gitlabGetJobLogTool: ToolConfig MAX_LOG_CHARS, }, } }, @@ -69,7 +78,11 @@ export const gitlabGetJobLogTool: ToolConfig = { + id: 'gitlab_invite_member', + name: 'GitLab Invite Member', + description: 'Invite a person to a GitLab project or group by email address', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to invite (comma-separated for multiple)', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level: 0 (No access), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + inviteSource: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identifier recorded as the source of the invitation (for attribution)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + // GitLab accepts a comma-separated list of emails in a single `email` + // field. Normalize surrounding whitespace so "a@b.com, c@d.com" invites + // both addresses rather than sending a malformed second entry. + const email = String(params.email) + .split(',') + .map((address) => address.trim()) + .filter(Boolean) + .join(',') + + const body: Record = { + email, + access_level: params.accessLevel, + } + + if (params.expiresAt) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + if (params.inviteSource?.trim()) body.invite_source = params.inviteSource.trim() + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const data = await response.json() + + // GitLab returns { status: 'error', message: {...} } with a 201 when an + // individual email fails, so surface the failure rather than reporting success. + if (data?.status === 'error') { + return { + success: false, + error: + typeof data.message === 'string' ? data.message : JSON.stringify(data.message ?? data), + output: { + status: data.status, + message: data.message, + }, + } + } + + return { + success: true, + output: { + status: data?.status ?? 'success', + message: data?.message, + }, + } + }, + + outputs: { + status: { + type: 'string', + description: 'Invitation status returned by GitLab', + }, + message: { + type: 'object', + description: 'Per-email result detail, if any', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_access_requests.ts b/apps/sim/tools/gitlab/list_access_requests.ts new file mode 100644 index 00000000000..0a1d97eb7c3 --- /dev/null +++ b/apps/sim/tools/gitlab/list_access_requests.ts @@ -0,0 +1,105 @@ +import type { + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListAccessRequestsTool: ToolConfig< + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse +> = { + id: 'gitlab_list_access_requests', + name: 'GitLab List Access Requests', + description: 'List pending access requests for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const accessRequests = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + accessRequests, + total: total ? Number.parseInt(total, 10) : accessRequests.length, + }, + } + }, + + outputs: { + accessRequests: { + type: 'array', + description: 'List of pending access requests', + }, + total: { + type: 'number', + description: 'Total number of access requests', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_branches.ts b/apps/sim/tools/gitlab/list_branches.ts index 2943c250285..bbf4dfd5e45 100644 --- a/apps/sim/tools/gitlab/list_branches.ts +++ b/apps/sim/tools/gitlab/list_branches.ts @@ -28,7 +28,7 @@ export const gitlabListBranchesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, search: { type: 'string', diff --git a/apps/sim/tools/gitlab/list_commits.ts b/apps/sim/tools/gitlab/list_commits.ts index b3be01049d2..cccfaf2b36d 100644 --- a/apps/sim/tools/gitlab/list_commits.ts +++ b/apps/sim/tools/gitlab/list_commits.ts @@ -26,7 +26,7 @@ export const gitlabListCommitsTool: ToolConfig = { + id: 'gitlab_list_invitations', + name: 'GitLab List Invitations', + description: 'List pending email invitations for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter invitations by invited email', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.query) queryParams.append('query', params.query) + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const invitations = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + invitations, + total: total ? Number.parseInt(total, 10) : invitations.length, + }, + } + }, + + outputs: { + invitations: { + type: 'array', + description: 'List of pending invitations', + }, + total: { + type: 'number', + description: 'Total number of invitations', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_issues.ts b/apps/sim/tools/gitlab/list_issues.ts index a2bb9d1416b..9d31a58cc78 100644 --- a/apps/sim/tools/gitlab/list_issues.ts +++ b/apps/sim/tools/gitlab/list_issues.ts @@ -25,7 +25,7 @@ export const gitlabListIssuesTool: ToolConfig = + { + id: 'gitlab_list_members', + name: 'GitLab List Members', + description: + 'List members of a GitLab project or group. Includes members inherited from ancestor groups by default.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + directOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, returns only direct members. Defaults to false, which also returns members inherited from ancestor groups.', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter members by name, email, or username', + }, + userIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user IDs to filter the results to', + }, + state: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Filter inherited-member results by state: 'awaiting' or 'active' (Premium/Ultimate; only applies when inherited members are included)", + }, + showSeatInfo: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include seat information for each member', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const membersPath = params.directOnly ? 'members' : 'members/all' + const queryParams = new URLSearchParams() + + if (params.query) queryParams.append('query', params.query) + if (params.userIds) { + for (const id of String(params.userIds).split(',')) { + const trimmed = id.trim() + if (trimmed) queryParams.append('user_ids[]', trimmed) + } + } + if (params.state && !params.directOnly) queryParams.append('state', params.state) + if (params.showSeatInfo) queryParams.append('show_seat_info', 'true') + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/${membersPath}${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const members = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + members, + total: total ? Number.parseInt(total, 10) : members.length, + }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'List of project or group members', + }, + total: { + type: 'number', + description: 'Total number of members', + }, + }, + } diff --git a/apps/sim/tools/gitlab/list_merge_requests.ts b/apps/sim/tools/gitlab/list_merge_requests.ts index f495320775d..27869500a85 100644 --- a/apps/sim/tools/gitlab/list_merge_requests.ts +++ b/apps/sim/tools/gitlab/list_merge_requests.ts @@ -31,7 +31,7 @@ export const gitlabListMergeRequestsTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, state: { type: 'string', diff --git a/apps/sim/tools/gitlab/list_pipeline_jobs.ts b/apps/sim/tools/gitlab/list_pipeline_jobs.ts index db63ccb8df6..d77e3620057 100644 --- a/apps/sim/tools/gitlab/list_pipeline_jobs.ts +++ b/apps/sim/tools/gitlab/list_pipeline_jobs.ts @@ -31,7 +31,7 @@ export const gitlabListPipelineJobsTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, pipelineId: { type: 'number', diff --git a/apps/sim/tools/gitlab/list_pipelines.ts b/apps/sim/tools/gitlab/list_pipelines.ts index bb21081e2ea..e632e2476ab 100644 --- a/apps/sim/tools/gitlab/list_pipelines.ts +++ b/apps/sim/tools/gitlab/list_pipelines.ts @@ -28,7 +28,7 @@ export const gitlabListPipelinesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, ref: { type: 'string', @@ -41,7 +41,7 @@ export const gitlabListPipelinesTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Filter by status (created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled)', + 'Filter by status (created, waiting_for_resource, preparing, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled, waiting_for_callback)', }, orderBy: { type: 'string', diff --git a/apps/sim/tools/gitlab/list_releases.ts b/apps/sim/tools/gitlab/list_releases.ts index cdb01bdd7dc..e6d8e281090 100644 --- a/apps/sim/tools/gitlab/list_releases.ts +++ b/apps/sim/tools/gitlab/list_releases.ts @@ -28,7 +28,7 @@ export const gitlabListReleasesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, orderBy: { type: 'string', diff --git a/apps/sim/tools/gitlab/list_repository_tree.ts b/apps/sim/tools/gitlab/list_repository_tree.ts index efbc439b852..045e7fea596 100644 --- a/apps/sim/tools/gitlab/list_repository_tree.ts +++ b/apps/sim/tools/gitlab/list_repository_tree.ts @@ -31,7 +31,7 @@ export const gitlabListRepositoryTreeTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, path: { type: 'string', diff --git a/apps/sim/tools/gitlab/list_saml_group_links.ts b/apps/sim/tools/gitlab/list_saml_group_links.ts new file mode 100644 index 00000000000..b46b61ea411 --- /dev/null +++ b/apps/sim/tools/gitlab/list_saml_group_links.ts @@ -0,0 +1,81 @@ +import type { + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListSamlGroupLinksTool: ToolConfig< + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse +> = { + id: 'gitlab_list_saml_group_links', + name: 'GitLab List SAML Group Links', + description: + 'List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or path (e.g. my-org/my-group)', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const samlGroupLinks = await response.json() + + return { + success: true, + output: { + samlGroupLinks, + total: Array.isArray(samlGroupLinks) ? samlGroupLinks.length : 0, + }, + } + }, + + outputs: { + samlGroupLinks: { + type: 'array', + description: 'List of SAML group links', + }, + total: { + type: 'number', + description: 'Number of SAML group links', + }, + }, +} diff --git a/apps/sim/tools/gitlab/merge_merge_request.ts b/apps/sim/tools/gitlab/merge_merge_request.ts index 4991e67e209..dd86ab9cb24 100644 --- a/apps/sim/tools/gitlab/merge_merge_request.ts +++ b/apps/sim/tools/gitlab/merge_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabMergeMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', @@ -89,8 +89,13 @@ export const gitlabMergeMergeRequestTool: ToolConfig< if (params.squash !== undefined) body.squash = params.squash if (params.shouldRemoveSourceBranch !== undefined) body.should_remove_source_branch = params.shouldRemoveSourceBranch - if (params.mergeWhenPipelineSucceeds !== undefined) + if (params.mergeWhenPipelineSucceeds !== undefined) { + // `merge_when_pipeline_succeeds` was deprecated in GitLab 17.11 in + // favor of `auto_merge`; send both so older self-managed instances + // (which ignore unknown params) keep working. + body.auto_merge = params.mergeWhenPipelineSucceeds body.merge_when_pipeline_succeeds = params.mergeWhenPipelineSucceeds + } return body }, diff --git a/apps/sim/tools/gitlab/play_job.ts b/apps/sim/tools/gitlab/play_job.ts index bd8a37effca..0640fbb8d33 100644 --- a/apps/sim/tools/gitlab/play_job.ts +++ b/apps/sim/tools/gitlab/play_job.ts @@ -25,7 +25,7 @@ export const gitlabPlayJobTool: ToolConfig ({ + 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), + body: (params) => { + const body: Record = {} + if (params.jobVariables && params.jobVariables.length > 0) { + body.job_variables_attributes = params.jobVariables + } + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/remove_member.ts b/apps/sim/tools/gitlab/remove_member.ts new file mode 100644 index 00000000000..5ee3d2ce5bb --- /dev/null +++ b/apps/sim/tools/gitlab/remove_member.ts @@ -0,0 +1,100 @@ +import type { GitLabRemoveMemberParams, GitLabRemoveMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabRemoveMemberTool: ToolConfig< + GitLabRemoveMemberParams, + GitLabRemoveMemberResponse +> = { + id: 'gitlab_remove_member', + name: 'GitLab Remove Member', + description: 'Remove a member from a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the member to remove', + }, + skipSubresources: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Skip deleting the member from subgroups and projects below the target (defaults to false)', + }, + unassignIssuables: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Unassign the member from all issues and merge requests in the target (defaults to false)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + if (params.skipSubresources) queryParams.append('skip_subresources', 'true') + if (params.unassignIssuables) queryParams.append('unassign_issuables', 'true') + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}${query ? `?${query}` : ''}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the member was removed successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/retry_pipeline.ts b/apps/sim/tools/gitlab/retry_pipeline.ts index 470ade6d9f0..9ef1b4a1fd6 100644 --- a/apps/sim/tools/gitlab/retry_pipeline.ts +++ b/apps/sim/tools/gitlab/retry_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabRetryPipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, pipelineId: { type: 'number', diff --git a/apps/sim/tools/gitlab/revoke_invitation.ts b/apps/sim/tools/gitlab/revoke_invitation.ts new file mode 100644 index 00000000000..3d3166169e8 --- /dev/null +++ b/apps/sim/tools/gitlab/revoke_invitation.ts @@ -0,0 +1,86 @@ +import type { + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabRevokeInvitationTool: ToolConfig< + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse +> = { + id: 'gitlab_revoke_invitation', + name: 'GitLab Revoke Invitation', + description: 'Revoke a pending email invitation to a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the invitation to revoke', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const encodedEmail = encodeURIComponent(String(params.email).trim()) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations/${encodedEmail}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the invitation was revoked successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/search_users.ts b/apps/sim/tools/gitlab/search_users.ts new file mode 100644 index 00000000000..f0d152485f3 --- /dev/null +++ b/apps/sim/tools/gitlab/search_users.ts @@ -0,0 +1,93 @@ +import type { GitLabSearchUsersParams, GitLabSearchUsersResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabSearchUsersTool: ToolConfig = + { + id: 'gitlab_search_users', + name: 'GitLab Search Users', + description: + 'Search for GitLab users by name, username, or email. Email matches must be exact; private emails match only with an admin token. Use this to resolve an email to a user ID before adding a member.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + search: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name, username, or email to search for', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + queryParams.append('search', String(params.search).trim()) + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + return `${getGitLabApiBase(params.host)}/users?${queryParams.toString()}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const users = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + users, + total: total ? Number.parseInt(total, 10) : users.length, + }, + } + }, + + outputs: { + users: { + type: 'array', + description: 'List of matching users', + }, + total: { + type: 'number', + description: 'Total number of matching users', + }, + }, + } diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 694244d6325..84f8cc9f08b 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -1,3 +1,4 @@ +import type { GitLabResourceType } from '@/tools/gitlab/utils' import type { ToolResponse } from '@/tools/types' interface GitLabProject { @@ -252,7 +253,16 @@ export interface GitLabListIssuesParams extends GitLabBaseParams { assigneeId?: number milestoneTitle?: string search?: string - orderBy?: 'created_at' | 'updated_at' + orderBy?: + | 'created_at' + | 'updated_at' + | 'priority' + | 'due_date' + | 'relative_position' + | 'label_priority' + | 'milestone_due' + | 'popularity' + | 'weight' sort?: 'asc' | 'desc' perPage?: number page?: number @@ -294,11 +304,19 @@ export interface GitLabDeleteIssueParams extends GitLabBaseParams { export interface GitLabListMergeRequestsParams extends GitLabBaseParams { projectId: string | number - state?: 'opened' | 'closed' | 'merged' | 'all' + state?: 'opened' | 'closed' | 'locked' | 'merged' | 'all' labels?: string sourceBranch?: string targetBranch?: string - orderBy?: 'created_at' | 'updated_at' + orderBy?: + | 'created_at' + | 'updated_at' + | 'merged_at' + | 'label_priority' + | 'priority' + | 'milestone_due' + | 'popularity' + | 'title' sort?: 'asc' | 'desc' perPage?: number page?: number @@ -359,10 +377,12 @@ export interface GitLabListPipelinesParams extends GitLabBaseParams { | 'running' | 'success' | 'failed' + | 'canceling' | 'canceled' | 'skipped' | 'manual' | 'scheduled' + | 'waiting_for_callback' orderBy?: 'id' | 'status' | 'ref' | 'updated_at' | 'user_id' sort?: 'asc' | 'desc' perPage?: number @@ -378,6 +398,8 @@ export interface GitLabCreatePipelineParams extends GitLabBaseParams { projectId: string | number ref: string variables?: Array<{ key: string; value: string; variable_type?: 'env_var' | 'file' }> + /** Pipeline inputs (spec:inputs) as a key/value object. */ + inputs?: Record } export interface GitLabRetryPipelineParams extends GitLabBaseParams { @@ -413,6 +435,8 @@ export interface GitLabCompareBranchesParams extends GitLabBaseParams { from: string to: string straight?: boolean + fromProjectId?: string | number + unidiff?: boolean } export interface GitLabListRepositoryTreeParams extends GitLabBaseParams { @@ -436,6 +460,10 @@ export interface GitLabCreateFileParams extends GitLabBaseParams { branch: string content: string commitMessage: string + startBranch?: string + authorName?: string + authorEmail?: string + executeFilemode?: boolean } export interface GitLabUpdateFileParams extends GitLabBaseParams { @@ -445,6 +473,10 @@ export interface GitLabUpdateFileParams extends GitLabBaseParams { content: string commitMessage: string lastCommitId?: string + startBranch?: string + authorName?: string + authorEmail?: string + executeFilemode?: boolean } export interface GitLabListCommitsParams extends GitLabBaseParams { @@ -486,18 +518,21 @@ export interface GitLabGetJobLogParams extends GitLabBaseParams { export interface GitLabPlayJobParams extends GitLabBaseParams { projectId: string | number jobId: number + jobVariables?: Array<{ key: string; value: string }> } export interface GitLabCreateIssueNoteParams extends GitLabBaseParams { projectId: string | number issueIid: number body: string + internal?: boolean } export interface GitLabCreateMergeRequestNoteParams extends GitLabBaseParams { projectId: string | number mergeRequestIid: number body: string + internal?: boolean } export interface GitLabListReleasesParams extends GitLabBaseParams { @@ -516,6 +551,13 @@ export interface GitLabCreateReleaseParams extends GitLabBaseParams { ref?: string releasedAt?: string milestones?: string[] + tagMessage?: string + assetLinks?: Array<{ + name: string + url: string + link_type?: 'other' | 'runbook' | 'image' | 'package' + direct_asset_path?: string + }> } export interface GitLabListProjectsResponse extends ToolResponse { @@ -692,6 +734,7 @@ export interface GitLabGetFileResponse extends ToolResponse { blobId?: string | null lastCommitId?: string | null content?: string + truncated?: boolean } } @@ -721,6 +764,7 @@ export interface GitLabGetMergeRequestChangesResponse extends ToolResponse { mergeRequestIid?: number | null changes?: GitLabMergeRequestChange[] changesCount?: number + hasMore?: boolean } } @@ -742,6 +786,7 @@ export interface GitLabListPipelineJobsResponse extends ToolResponse { export interface GitLabGetJobLogResponse extends ToolResponse { output: { log?: string + truncated?: boolean } } @@ -754,6 +799,322 @@ export interface GitLabPlayJobResponse extends ToolResponse { } } +interface GitLabMember { + id: number + username: string + name: string + state: string + access_level: number + web_url?: string + expires_at?: string | null + membership_state?: string + member_role?: { id: number; name: string } | null +} + +interface GitLabInvitation { + id?: number + invite_email: string + access_level: number + created_at?: string + expires_at?: string | null + user_name?: string + created_by_name?: string + invite_token?: string + member_role_id?: number | null +} + +interface GitLabAccessRequest { + id: number + username: string + name: string + state: string + requested_at: string + access_level?: number + web_url?: string +} + +interface GitLabSamlGroupLink { + name: string + access_level: number + member_role_id?: number | null + provider?: string | null +} + +interface GitLabUser { + id: number + username: string + name: string + email?: string + state: string + web_url?: string + is_admin?: boolean + created_at?: string +} + +/** + * The access resources (`/members`, `/invitations`, `/access_requests`) exist + * on both projects and groups; the tool receives `resourceType` to select which. + */ +interface GitLabResourceScopedParams extends GitLabBaseParams { + resourceType: GitLabResourceType + resourceId: string | number +} + +export interface GitLabListMembersParams extends GitLabResourceScopedParams { + /** When true, returns only direct members (`/members`). Defaults to false, which returns inherited members too (`/members/all`). */ + directOnly?: boolean + query?: string + /** Comma-separated user IDs to filter to. */ + userIds?: string + /** 'awaiting' | 'active' — inherited-member listings only (Premium/Ultimate). */ + state?: string + showSeatInfo?: boolean + perPage?: number + page?: number +} + +export interface GitLabAddMemberParams extends GitLabResourceScopedParams { + /** Provide either userId or username to identify the user. */ + userId?: number + username?: string + accessLevel: number + expiresAt?: string + memberRoleId?: number +} + +export interface GitLabUpdateMemberParams extends GitLabResourceScopedParams { + userId: number + accessLevel: number + expiresAt?: string + memberRoleId?: number +} + +export interface GitLabRemoveMemberParams extends GitLabResourceScopedParams { + userId: number + skipSubresources?: boolean + unassignIssuables?: boolean +} + +export interface GitLabInviteMemberParams extends GitLabResourceScopedParams { + email: string + accessLevel: number + expiresAt?: string + memberRoleId?: number + inviteSource?: string +} + +export interface GitLabListInvitationsParams extends GitLabResourceScopedParams { + query?: string + perPage?: number + page?: number +} + +export interface GitLabUpdateInvitationParams extends GitLabResourceScopedParams { + email: string + accessLevel?: number + expiresAt?: string +} + +export interface GitLabRevokeInvitationParams extends GitLabResourceScopedParams { + email: string +} + +export interface GitLabListAccessRequestsParams extends GitLabResourceScopedParams { + perPage?: number + page?: number +} + +export interface GitLabApproveAccessRequestParams extends GitLabResourceScopedParams { + userId: number + accessLevel?: number +} + +export interface GitLabDenyAccessRequestParams extends GitLabResourceScopedParams { + userId: number +} + +export interface GitLabListSamlGroupLinksParams extends GitLabBaseParams { + groupId: string | number +} + +export interface GitLabAddSamlGroupLinkParams extends GitLabBaseParams { + groupId: string | number + samlGroupName: string + accessLevel: number + memberRoleId?: number + provider?: string +} + +export interface GitLabDeleteSamlGroupLinkParams extends GitLabBaseParams { + groupId: string | number + samlGroupName: string + /** Provider name; required by GitLab when multiple links share the same SAML group name. */ + provider?: string +} + +export interface GitLabSearchUsersParams extends GitLabBaseParams { + search: string + perPage?: number + page?: number +} + +export interface GitLabCreateUserParams extends GitLabBaseParams { + email: string + username: string + name: string + password?: string + resetPassword?: boolean + forceRandomPassword?: boolean + admin?: boolean + skipConfirmation?: boolean +} + +export interface GitLabUpdateUserParams extends GitLabBaseParams { + userId: number + email?: string + username?: string + name?: string + admin?: boolean +} + +export interface GitLabDeleteUserParams extends GitLabBaseParams { + userId: number + hardDelete?: boolean +} + +/** Shared shape for the single-user POST actions (block/unblock/deactivate/activate/ban/unban/approve/reject). */ +export interface GitLabUserActionParams extends GitLabBaseParams { + userId: number +} + +export interface GitLabDeleteUserIdentityParams extends GitLabBaseParams { + userId: number + provider: string +} + +export interface GitLabListMembersResponse extends ToolResponse { + output: { + members?: GitLabMember[] + total?: number + } +} + +export interface GitLabAddMemberResponse extends ToolResponse { + output: { + member?: GitLabMember + /** True when the user was already a member (409) — treated as a soft success so workflows are re-runnable. */ + alreadyMember?: boolean + } +} + +export interface GitLabUpdateMemberResponse extends ToolResponse { + output: { + member?: GitLabMember + } +} + +export interface GitLabRemoveMemberResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabInviteMemberResponse extends ToolResponse { + output: { + status?: string + message?: unknown + } +} + +export interface GitLabListInvitationsResponse extends ToolResponse { + output: { + invitations?: GitLabInvitation[] + total?: number + } +} + +export interface GitLabUpdateInvitationResponse extends ToolResponse { + output: { + invitation?: GitLabInvitation + } +} + +export interface GitLabRevokeInvitationResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabListAccessRequestsResponse extends ToolResponse { + output: { + accessRequests?: GitLabAccessRequest[] + total?: number + } +} + +export interface GitLabApproveAccessRequestResponse extends ToolResponse { + output: { + accessRequest?: GitLabAccessRequest + } +} + +export interface GitLabDenyAccessRequestResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabListSamlGroupLinksResponse extends ToolResponse { + output: { + samlGroupLinks?: GitLabSamlGroupLink[] + total?: number + } +} + +export interface GitLabSamlGroupLinkResponse extends ToolResponse { + output: { + samlGroupLink?: GitLabSamlGroupLink + } +} + +export interface GitLabDeleteSamlGroupLinkResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabSearchUsersResponse extends ToolResponse { + output: { + users?: GitLabUser[] + total?: number + } +} + +export interface GitLabUserResponse extends ToolResponse { + output: { + user?: GitLabUser + } +} + +export interface GitLabDeleteUserResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabUserActionResponse extends ToolResponse { + output: { + success?: boolean + user?: GitLabUser + } +} + +export interface GitLabDeleteUserIdentityResponse extends ToolResponse { + output: { + success?: boolean + } +} + export type GitLabResponse = | GitLabListProjectsResponse | GitLabGetProjectResponse @@ -789,3 +1150,22 @@ export type GitLabResponse = | GitLabListPipelineJobsResponse | GitLabGetJobLogResponse | GitLabPlayJobResponse + | GitLabListMembersResponse + | GitLabAddMemberResponse + | GitLabUpdateMemberResponse + | GitLabRemoveMemberResponse + | GitLabInviteMemberResponse + | GitLabListInvitationsResponse + | GitLabUpdateInvitationResponse + | GitLabRevokeInvitationResponse + | GitLabListAccessRequestsResponse + | GitLabApproveAccessRequestResponse + | GitLabDenyAccessRequestResponse + | GitLabListSamlGroupLinksResponse + | GitLabSamlGroupLinkResponse + | GitLabDeleteSamlGroupLinkResponse + | GitLabSearchUsersResponse + | GitLabUserResponse + | GitLabDeleteUserResponse + | GitLabUserActionResponse + | GitLabDeleteUserIdentityResponse diff --git a/apps/sim/tools/gitlab/update_file.ts b/apps/sim/tools/gitlab/update_file.ts index 38a7beece3d..c3381453d8a 100644 --- a/apps/sim/tools/gitlab/update_file.ts +++ b/apps/sim/tools/gitlab/update_file.ts @@ -25,7 +25,7 @@ export const gitlabUpdateFileTool: ToolConfig = { + id: 'gitlab_update_invitation', + name: 'GitLab Update Invitation', + description: 'Update a pending invitation to a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the invitation to update', + }, + accessLevel: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'New access level: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Access expiration date (ISO 8601, e.g. 2026-12-31T00:00:00Z; date-only also accepted). At least one of accessLevel or expiresAt must be provided.', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const encodedEmail = encodeURIComponent(String(params.email).trim()) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations/${encodedEmail}` + }, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = {} + + if (params.accessLevel !== undefined) body.access_level = params.accessLevel + // Send explicit empty string too, which clears an existing expiration. + if (params.expiresAt !== undefined) body.expires_at = params.expiresAt + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const invitation = await response.json() + + return { + success: true, + output: { + invitation, + }, + } + }, + + outputs: { + invitation: { + type: 'object', + description: 'The updated invitation', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_issue.ts b/apps/sim/tools/gitlab/update_issue.ts index b2e8b8503aa..79535938804 100644 --- a/apps/sim/tools/gitlab/update_issue.ts +++ b/apps/sim/tools/gitlab/update_issue.ts @@ -26,7 +26,7 @@ export const gitlabUpdateIssueTool: ToolConfig = { + id: 'gitlab_update_member', + name: 'GitLab Update Member', + description: "Update a member's access level in a GitLab project or group", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or path (e.g. mygroup/myproject)', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the member to update', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'New access level: 0 (No access), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration.', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Custom member role ID (GitLab Ultimate only). Warning: when omitted, GitLab removes any custom role the member currently holds.', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}` + }, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + access_level: params.accessLevel, + } + + // Send explicit empty string too, which clears an existing expiration. + if (params.expiresAt !== undefined) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const member = await response.json() + + return { + success: true, + output: { + member, + }, + } + }, + + outputs: { + member: { + type: 'object', + description: 'The updated member', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_merge_request.ts b/apps/sim/tools/gitlab/update_merge_request.ts index a0930567db9..037e8398d03 100644 --- a/apps/sim/tools/gitlab/update_merge_request.ts +++ b/apps/sim/tools/gitlab/update_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', @@ -97,7 +97,8 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< type: 'boolean', required: false, visibility: 'user-or-llm', - description: 'Mark as draft (work in progress)', + description: + 'Mark as draft or remove draft status (applied via the "Draft:" title prefix; requires title to be set)', }, }, @@ -114,7 +115,14 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< body: (params) => { const body: Record = {} - if (params.title) body.title = params.title + // GitLab has no `draft` body param — draft status is conveyed via the + // "Draft:" title prefix, so it can only be changed when a title is sent. + if (params.title) { + let title = params.title + if (params.draft === true && !/^\s*draft:/i.test(title)) title = `Draft: ${title}` + if (params.draft === false) title = title.replace(/^\s*draft:\s*/i, '') + body.title = title + } if (params.description !== undefined) body.description = params.description if (params.stateEvent) body.state_event = params.stateEvent if (params.labels !== undefined) body.labels = params.labels @@ -124,7 +132,6 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/update_user.ts b/apps/sim/tools/gitlab/update_user.ts new file mode 100644 index 00000000000..6a7831eac29 --- /dev/null +++ b/apps/sim/tools/gitlab/update_user.ts @@ -0,0 +1,105 @@ +import type { GitLabUpdateUserParams, GitLabUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabUpdateUserTool: ToolConfig = { + id: 'gitlab_update_user', + name: 'GitLab Update User', + description: + 'Modify an existing GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to modify', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "The user's new email address (GitLab only allows changing to one of the user's existing verified secondary emails)", + }, + username: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "The user's new username", + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "The user's new display name", + }, + admin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the user is an administrator', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users/${params.userId}`, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = {} + + if (params.email) body.email = params.email + if (params.username) body.username = params.username + if (params.name) body.name = params.name + // Strict boolean check: an untouched block switch serializes as `null`, + // which must not be sent (GitLab would treat it as a demotion). + if (typeof params.admin === 'boolean') body.admin = params.admin + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const user = await response.json() + + return { + success: true, + output: { + user, + }, + } + }, + + outputs: { + user: { + type: 'object', + description: 'The updated user', + }, + }, +} diff --git a/apps/sim/tools/gitlab/user_status_actions.ts b/apps/sim/tools/gitlab/user_status_actions.ts new file mode 100644 index 00000000000..05f891f3c3d --- /dev/null +++ b/apps/sim/tools/gitlab/user_status_actions.ts @@ -0,0 +1,135 @@ +import type { GitLabUserActionParams, GitLabUserActionResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +/** + * The GitLab admin user-state endpoints (`POST /users/:id/{block,unblock,...}`) + * are identical in shape - a single `user_id` path param, no body, and a boolean + * or user-object response. This factory builds one `ToolConfig` per action so + * each is registered and selectable individually while the wiring stays DRY. + * All require an administrator token with `admin_mode` on the instance. + */ +function createUserStatusActionTool( + action: string, + name: string, + description: string +): ToolConfig { + return { + id: `gitlab_${action}_user`, + name, + description, + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to act on', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users/${params.userId}/${action}`, + method: 'POST', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + // These endpoints return `true`, `{ message: 'Success' }`, or the updated + // user object. Only surface objects that are actually a user record. + const data = await response.json().catch(() => null) + const user = data && typeof data === 'object' && 'id' in data ? data : undefined + + return { + success: true, + output: { + success: true, + user, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the action succeeded', + }, + user: { + type: 'object', + description: 'The updated user, when returned by GitLab', + }, + }, + } +} + +export const gitlabBlockUserTool = createUserStatusActionTool( + 'block', + 'GitLab Block User', + 'Block a GitLab user, preventing them from signing in or accessing the instance' +) + +export const gitlabUnblockUserTool = createUserStatusActionTool( + 'unblock', + 'GitLab Unblock User', + 'Unblock a previously blocked GitLab user' +) + +export const gitlabDeactivateUserTool = createUserStatusActionTool( + 'deactivate', + 'GitLab Deactivate User', + 'Deactivate a dormant GitLab user' +) + +export const gitlabActivateUserTool = createUserStatusActionTool( + 'activate', + 'GitLab Activate User', + 'Reactivate a deactivated GitLab user' +) + +export const gitlabBanUserTool = createUserStatusActionTool( + 'ban', + 'GitLab Ban User', + 'Ban a GitLab user' +) + +export const gitlabUnbanUserTool = createUserStatusActionTool( + 'unban', + 'GitLab Unban User', + 'Unban a previously banned GitLab user' +) + +export const gitlabApproveUserTool = createUserStatusActionTool( + 'approve', + 'GitLab Approve User', + 'Approve a GitLab user whose signup is pending administrator approval' +) + +export const gitlabRejectUserTool = createUserStatusActionTool( + 'reject', + 'GitLab Reject User', + 'Reject a GitLab user whose signup is pending administrator approval' +) diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index f7eca36aef8..792aa66766e 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getGitLabApiBase, normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils' +import { + getGitLabApiBase, + getGitLabResourcePath, + normalizeGitLabHost, + UnsafeGitLabHostError, +} from '@/tools/gitlab/utils' describe('normalizeGitLabHost', () => { it('defaults to gitlab.com when the host is empty, blank, or not a string', () => { @@ -69,3 +74,17 @@ describe('getGitLabApiBase', () => { expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError) }) }) + +describe('getGitLabResourcePath', () => { + it('builds project and group path segments', () => { + expect(getGitLabResourcePath('project', 42)).toBe('projects/42') + expect(getGitLabResourcePath('group', 7)).toBe('groups/7') + }) + + it('URL-encodes namespaced paths and trims whitespace', () => { + expect(getGitLabResourcePath('project', ' mygroup/myproject ')).toBe( + 'projects/mygroup%2Fmyproject' + ) + expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild') + }) +}) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 6334a7030ee..7bc455a8a83 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -66,3 +66,24 @@ export function normalizeGitLabHost(rawHost: unknown): string { export function getGitLabApiBase(rawHost: unknown): string { return `https://${normalizeGitLabHost(rawHost)}/api/v4` } + +/** + * A GitLab access/membership resource is scoped either to a project or a group. + * The two share an identical endpoint surface (`/members`, `/invitations`, + * `/access_requests`) that differs only in the leading path segment. + */ +export type GitLabResourceType = 'project' | 'group' + +/** + * Builds the path segment for a project- or group-scoped access resource, e.g. + * `projects/mygroup%2Fmyproject` or `groups/42`. The id is URL-encoded so that + * URL-encoded paths (`mygroup/myproject`) and numeric ids both work. + */ +export function getGitLabResourcePath( + resourceType: GitLabResourceType, + resourceId: string | number +): string { + const encodedId = encodeURIComponent(String(resourceId).trim()) + const segment = resourceType === 'group' ? 'groups' : 'projects' + return `${segment}/${encodedId}` +} diff --git a/apps/sim/tools/grain/create_hook_v2.ts b/apps/sim/tools/grain/create_hook_v2.ts new file mode 100644 index 00000000000..3f5aeb27c26 --- /dev/null +++ b/apps/sim/tools/grain/create_hook_v2.ts @@ -0,0 +1,104 @@ +import type { GrainCreateHookV2Params, GrainCreateHookV2Response } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainCreateHookV2Tool: ToolConfig = + { + id: 'grain_create_hook_v2', + name: 'Grain Create Webhook', + description: 'Create a webhook for a specific Grain event type (v2 API)', + version: '2.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal or Workspace Access Token)', + }, + hookUrl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Webhook endpoint URL. Grain performs a reachability test on creation — the endpoint must respond 2xx.', + }, + hookType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Event type the hook subscribes to. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status', + }, + include: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Optional include object controlling payload richness. For recording hooks: {"participants": true, "highlights": true, "ai_summary": true}. For highlight hooks: {"transcript": true, "speakers": true}.', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/hooks/create', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + body: (params) => { + const body: Record = { + hook_url: params.hookUrl.trim(), + hook_type: params.hookType.trim(), + } + if (params.include && Object.keys(params.include).length > 0) { + body.include = params.include + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to create webhook') + } + + if (!data?.id) { + throw new Error('Grain webhook created but response did not include a webhook id') + } + + return { + success: true, + output: data, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Hook UUID', + }, + enabled: { + type: 'boolean', + description: 'Whether hook is active', + }, + hook_url: { + type: 'string', + description: 'The webhook URL', + }, + hook_type: { + type: 'string', + description: 'Event type the hook subscribes to', + }, + include: { + type: 'json', + description: 'Include object the hook was created with', + }, + inserted_at: { + type: 'string', + description: 'ISO8601 creation timestamp', + }, + }, + } diff --git a/apps/sim/tools/grain/delete_hook.ts b/apps/sim/tools/grain/delete_hook.ts index b2b67184f88..1d97272af35 100644 --- a/apps/sim/tools/grain/delete_hook.ts +++ b/apps/sim/tools/grain/delete_hook.ts @@ -33,7 +33,7 @@ export const grainDeleteHookTool: ToolConfig { if (!response.ok) { - const data = await response.json() + const data = await response.json().catch(() => ({})) throw new Error(data.error || data.message || 'Failed to delete webhook') } diff --git a/apps/sim/tools/grain/delete_hook_v2.ts b/apps/sim/tools/grain/delete_hook_v2.ts new file mode 100644 index 00000000000..a71131b0a4e --- /dev/null +++ b/apps/sim/tools/grain/delete_hook_v2.ts @@ -0,0 +1,56 @@ +import type { GrainDeleteHookV2Params, GrainDeleteHookV2Response } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainDeleteHookV2Tool: ToolConfig = + { + id: 'grain_delete_hook_v2', + name: 'Grain Delete Webhook', + description: 'Delete a webhook by ID (v2 API)', + version: '2.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal or Workspace Access Token)', + }, + hookId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The hook UUID to delete (e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890")', + }, + }, + + request: { + url: (params) => `https://api.grain.com/_/public-api/v2/hooks/${params.hookId.trim()}`, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || data.message || 'Failed to delete webhook') + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'True when webhook was successfully deleted', + }, + }, + } diff --git a/apps/sim/tools/grain/get_recording.ts b/apps/sim/tools/grain/get_recording.ts index f4a9f2cfd5d..5b9df4a6004 100644 --- a/apps/sim/tools/grain/get_recording.ts +++ b/apps/sim/tools/grain/get_recording.ts @@ -39,6 +39,12 @@ export const grainGetRecordingTool: ToolConfig `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId}`, + url: (params) => + `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId.trim()}`, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', @@ -62,7 +69,7 @@ export const grainGetRecordingTool: ToolConfig { - const include: Record = {} + const include: Record = {} if (params.includeHighlights) { include.highlights = true @@ -73,6 +80,9 @@ export const grainGetRecordingTool: ToolConfig - `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId}/transcript`, + `https://api.grain.com/_/public-api/v2/recordings/${params.recordingId.trim()}/transcript`, method: 'GET', headers: (params) => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/grain/index.ts b/apps/sim/tools/grain/index.ts index 98ba6187f88..ae02e97a222 100644 --- a/apps/sim/tools/grain/index.ts +++ b/apps/sim/tools/grain/index.ts @@ -1,9 +1,13 @@ -export { grainCreateHookTool } from './create_hook' -export { grainDeleteHookTool } from './delete_hook' -export { grainGetRecordingTool } from './get_recording' -export { grainGetTranscriptTool } from './get_transcript' -export { grainListHooksTool } from './list_hooks' -export { grainListMeetingTypesTool } from './list_meeting_types' -export { grainListRecordingsTool } from './list_recordings' -export { grainListTeamsTool } from './list_teams' -export { grainListViewsTool } from './list_views' +export { grainCreateHookTool } from '@/tools/grain/create_hook' +export { grainCreateHookV2Tool } from '@/tools/grain/create_hook_v2' +export { grainDeleteHookTool } from '@/tools/grain/delete_hook' +export { grainDeleteHookV2Tool } from '@/tools/grain/delete_hook_v2' +export { grainGetRecordingTool } from '@/tools/grain/get_recording' +export { grainGetTranscriptTool } from '@/tools/grain/get_transcript' +export { grainListHooksTool } from '@/tools/grain/list_hooks' +export { grainListHooksV2Tool } from '@/tools/grain/list_hooks_v2' +export { grainListMeetingTypesTool } from '@/tools/grain/list_meeting_types' +export { grainListRecordingsTool } from '@/tools/grain/list_recordings' +export { grainListTeamsTool } from '@/tools/grain/list_teams' +export { grainListViewsTool } from '@/tools/grain/list_views' +export * from '@/tools/grain/types' diff --git a/apps/sim/tools/grain/list_hooks_v2.ts b/apps/sim/tools/grain/list_hooks_v2.ts new file mode 100644 index 00000000000..f89e8413348 --- /dev/null +++ b/apps/sim/tools/grain/list_hooks_v2.ts @@ -0,0 +1,84 @@ +import type { GrainListHooksV2Params, GrainListHooksV2Response } from '@/tools/grain/types' +import type { ToolConfig } from '@/tools/types' + +export const grainListHooksV2Tool: ToolConfig = { + id: 'grain_list_hooks_v2', + name: 'Grain List Webhooks', + description: 'List webhooks for the account (v2 API)', + version: '2.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Grain API key (Personal or Workspace Access Token)', + }, + hookType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only return hooks with this event type. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status', + }, + state: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return hooks that are "enabled" or "disabled"', + }, + }, + + request: { + url: 'https://api.grain.com/_/public-api/v2/hooks', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + 'Public-Api-Version': '2025-10-31', + }), + body: (params) => { + const filter: Record = {} + if (params.hookType) { + filter.hook_type = params.hookType + } + if (params.state) { + filter.state = params.state + } + return Object.keys(filter).length > 0 ? { filter } : {} + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to list webhooks') + } + + return { + success: true, + output: { + hooks: data.hooks || [], + }, + } + }, + + outputs: { + hooks: { + type: 'array', + description: 'Array of hook objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Hook UUID' }, + enabled: { type: 'boolean', description: 'Whether hook is active' }, + hook_url: { type: 'string', description: 'Webhook URL' }, + hook_type: { type: 'string', description: 'Event type the hook subscribes to' }, + include: { type: 'object', description: 'Include object the hook was created with' }, + inserted_at: { type: 'string', description: 'Creation timestamp' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/grain/list_meeting_types.ts b/apps/sim/tools/grain/list_meeting_types.ts index dd12d11ba18..c4616442b56 100644 --- a/apps/sim/tools/grain/list_meeting_types.ts +++ b/apps/sim/tools/grain/list_meeting_types.ts @@ -42,7 +42,7 @@ export const grainListMeetingTypesTool: ToolConfig< return { success: true, output: { - meeting_types: data.meeting_types || data || [], + meeting_types: data.meeting_types || [], }, } }, diff --git a/apps/sim/tools/grain/list_recordings.ts b/apps/sim/tools/grain/list_recordings.ts index 221d5608a4b..96e0c442632 100644 --- a/apps/sim/tools/grain/list_recordings.ts +++ b/apps/sim/tools/grain/list_recordings.ts @@ -77,6 +77,12 @@ export const grainListRecordingsTool: ToolConfig< visibility: 'user-only', description: 'Include AI-generated summary', }, + includeAiActionItems: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include AI-detected action items', + }, }, request: { @@ -88,13 +94,13 @@ export const grainListRecordingsTool: ToolConfig< 'Public-Api-Version': '2025-10-31', }), body: (params) => { - const body: Record = {} + const body: Record = {} if (params.cursor) { body.cursor = params.cursor } - const filter: Record = {} + const filter: Record = {} if (params.beforeDatetime) { filter.before_datetime = params.beforeDatetime } @@ -117,7 +123,7 @@ export const grainListRecordingsTool: ToolConfig< body.filter = filter } - const include: Record = {} + const include: Record = {} if (params.includeHighlights) { include.highlights = true } @@ -127,6 +133,9 @@ export const grainListRecordingsTool: ToolConfig< if (params.includeAiSummary) { include.ai_summary = true } + if (params.includeAiActionItems) { + include.ai_action_items = true + } if (Object.keys(include).length > 0) { body.include = include } diff --git a/apps/sim/tools/grain/list_teams.ts b/apps/sim/tools/grain/list_teams.ts index 4ec8da6ecde..a92b85d3e58 100644 --- a/apps/sim/tools/grain/list_teams.ts +++ b/apps/sim/tools/grain/list_teams.ts @@ -36,7 +36,7 @@ export const grainListTeamsTool: ToolConfig + inserted_at: string +} + +export interface GrainCreateHookV2Params { + apiKey: string + hookUrl: string + hookType: GrainHookType + include?: Record +} + +export interface GrainCreateHookV2Response extends ToolResponse { + output: GrainHookV2 +} + +export interface GrainListHooksV2Params { + apiKey: string + hookType?: GrainHookType + state?: 'enabled' | 'disabled' +} + +export interface GrainListHooksV2Response extends ToolResponse { + output: { + hooks: GrainHookV2[] + } +} + +export interface GrainDeleteHookV2Params { + apiKey: string + hookId: string +} + +export interface GrainDeleteHookV2Response extends ToolResponse { + output: { + success: true + } +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 5e597c53422..80dee08c89b 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1002,7 +1002,7 @@ export async function executeTool( const scope = resolveToolScope(params, executionContext) const toolKind: 'skill' | 'custom' | 'mcp' | undefined = - normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill' + normalizedToolId === 'load_skill' ? 'skill' : isCustomTool(normalizedToolId) ? 'custom' @@ -1022,7 +1022,7 @@ export async function executeTool( }) } - if (normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill') { + if (normalizedToolId === 'load_skill') { const skillName = params.skill_name if (!skillName || !scope.workspaceId) { return { @@ -1198,6 +1198,9 @@ export async function executeTool( if (data.domain && !contextParams.domain) { contextParams.domain = data.domain } + if (data.authStyle && !contextParams.authStyle) { + contextParams.authStyle = data.authStyle + } logger.info(`[${requestId}] Successfully got access token for ${toolId}`) diff --git a/apps/sim/tools/pipedrive/create_activity.ts b/apps/sim/tools/pipedrive/create_activity.ts index 2a08c865a47..160227a47c1 100644 --- a/apps/sim/tools/pipedrive/create_activity.ts +++ b/apps/sim/tools/pipedrive/create_activity.ts @@ -3,6 +3,7 @@ import type { PipedriveCreateActivityParams, PipedriveCreateActivityResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveCreateActivity') @@ -23,6 +24,13 @@ export const pipedriveCreateActivityTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, subject: { type: 'string', required: true, @@ -82,17 +90,10 @@ export const pipedriveCreateActivityTool: ToolConfig< request: { url: () => 'https://api.pipedrive.com/v1/activities', method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = { subject: params.subject, diff --git a/apps/sim/tools/pipedrive/create_deal.ts b/apps/sim/tools/pipedrive/create_deal.ts index e328f121207..a3c2b8b0d05 100644 --- a/apps/sim/tools/pipedrive/create_deal.ts +++ b/apps/sim/tools/pipedrive/create_deal.ts @@ -3,6 +3,7 @@ import type { PipedriveCreateDealParams, PipedriveCreateDealResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveCreateDeal') @@ -23,6 +24,13 @@ export const pipedriveCreateDealTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, title: { type: 'string', required: true, @@ -82,17 +90,10 @@ export const pipedriveCreateDealTool: ToolConfig< request: { url: () => 'https://api.pipedrive.com/api/v2/deals', method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = { title: params.title, diff --git a/apps/sim/tools/pipedrive/create_lead.ts b/apps/sim/tools/pipedrive/create_lead.ts index 4f5b600d142..d6ed5817f44 100644 --- a/apps/sim/tools/pipedrive/create_lead.ts +++ b/apps/sim/tools/pipedrive/create_lead.ts @@ -3,6 +3,7 @@ import type { PipedriveCreateLeadParams, PipedriveCreateLeadResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveCreateLead') @@ -28,6 +29,13 @@ export const pipedriveCreateLeadTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, title: { type: 'string', required: true, @@ -81,17 +89,10 @@ export const pipedriveCreateLeadTool: ToolConfig< request: { url: () => 'https://api.pipedrive.com/v1/leads', method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { if (!params.person_id && !params.organization_id) { throw new Error('Either person_id or organization_id is required to create a lead') diff --git a/apps/sim/tools/pipedrive/create_project.ts b/apps/sim/tools/pipedrive/create_project.ts index 5ecba5e91e3..1a77aac13f6 100644 --- a/apps/sim/tools/pipedrive/create_project.ts +++ b/apps/sim/tools/pipedrive/create_project.ts @@ -3,6 +3,7 @@ import type { PipedriveCreateProjectParams, PipedriveCreateProjectResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveCreateProject') @@ -23,6 +24,13 @@ export const pipedriveCreateProjectTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, title: { type: 'string', required: true, @@ -52,17 +60,10 @@ export const pipedriveCreateProjectTool: ToolConfig< request: { url: () => 'https://api.pipedrive.com/v1/projects', method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = { title: params.title, diff --git a/apps/sim/tools/pipedrive/delete_lead.ts b/apps/sim/tools/pipedrive/delete_lead.ts index 3300f8a3dff..c79d038cdac 100644 --- a/apps/sim/tools/pipedrive/delete_lead.ts +++ b/apps/sim/tools/pipedrive/delete_lead.ts @@ -3,6 +3,7 @@ import type { PipedriveDeleteLeadParams, PipedriveDeleteLeadResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveDeleteLead') @@ -28,6 +29,13 @@ export const pipedriveDeleteLeadTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, lead_id: { type: 'string', required: true, @@ -39,16 +47,7 @@ export const pipedriveDeleteLeadTool: ToolConfig< request: { url: (params) => `https://api.pipedrive.com/v1/leads/${params.lead_id}`, method: 'DELETE', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/pipedrive/get_activities.ts b/apps/sim/tools/pipedrive/get_activities.ts index af732c6329c..28a13508926 100644 --- a/apps/sim/tools/pipedrive/get_activities.ts +++ b/apps/sim/tools/pipedrive/get_activities.ts @@ -4,6 +4,7 @@ import type { PipedriveGetActivitiesResponse, } from '@/tools/pipedrive/types' import { PIPEDRIVE_ACTIVITY_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetActivities') @@ -24,6 +25,13 @@ export const pipedriveGetActivitiesTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, user_id: { type: 'string', required: false, @@ -71,16 +79,7 @@ export const pipedriveGetActivitiesTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/pipedrive/get_all_deals.ts b/apps/sim/tools/pipedrive/get_all_deals.ts index d94e4bee93d..97a7934f5ac 100644 --- a/apps/sim/tools/pipedrive/get_all_deals.ts +++ b/apps/sim/tools/pipedrive/get_all_deals.ts @@ -7,6 +7,7 @@ import { PIPEDRIVE_DEAL_OUTPUT_PROPERTIES, PIPEDRIVE_METADATA_OUTPUT_PROPERTIES, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetAllDeals') @@ -27,6 +28,13 @@ export const pipedriveGetAllDealsTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, status: { type: 'string', required: false, @@ -93,16 +101,7 @@ export const pipedriveGetAllDealsTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response, params?: PipedriveGetAllDealsParams) => { diff --git a/apps/sim/tools/pipedrive/get_deal.ts b/apps/sim/tools/pipedrive/get_deal.ts index f61a3072997..736ab56561f 100644 --- a/apps/sim/tools/pipedrive/get_deal.ts +++ b/apps/sim/tools/pipedrive/get_deal.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import type { PipedriveGetDealParams, PipedriveGetDealResponse } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetDeal') @@ -17,6 +18,13 @@ export const pipedriveGetDealTool: ToolConfig `https://api.pipedrive.com/api/v2/deals/${params.deal_id}`, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/pipedrive/get_files.ts b/apps/sim/tools/pipedrive/get_files.ts index 7d964fcd0f0..54f8f5b0d2d 100644 --- a/apps/sim/tools/pipedrive/get_files.ts +++ b/apps/sim/tools/pipedrive/get_files.ts @@ -16,6 +16,13 @@ export const pipedriveGetFilesTool: ToolConfig ({ accessToken: params.accessToken, + authStyle: params.authStyle, sort: params.sort, limit: params.limit, start: params.start, diff --git a/apps/sim/tools/pipedrive/get_leads.ts b/apps/sim/tools/pipedrive/get_leads.ts index eb019405901..b180a11959e 100644 --- a/apps/sim/tools/pipedrive/get_leads.ts +++ b/apps/sim/tools/pipedrive/get_leads.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { PipedriveGetLeadsParams, PipedriveGetLeadsResponse } from '@/tools/pipedrive/types' import { PIPEDRIVE_LEAD_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetLeads') @@ -24,6 +25,13 @@ export const pipedriveGetLeadsTool: ToolConfig { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/pipedrive/get_mail_messages.ts b/apps/sim/tools/pipedrive/get_mail_messages.ts index a11ad61e037..c76401994f9 100644 --- a/apps/sim/tools/pipedrive/get_mail_messages.ts +++ b/apps/sim/tools/pipedrive/get_mail_messages.ts @@ -3,6 +3,7 @@ import type { PipedriveGetMailMessagesParams, PipedriveGetMailMessagesResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetMailMessages') @@ -28,6 +29,13 @@ export const pipedriveGetMailMessagesTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, folder: { type: 'string', required: false, @@ -61,16 +69,7 @@ export const pipedriveGetMailMessagesTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/pipedrive/get_mail_thread.ts b/apps/sim/tools/pipedrive/get_mail_thread.ts index c9b05112cbb..875edfee8b4 100644 --- a/apps/sim/tools/pipedrive/get_mail_thread.ts +++ b/apps/sim/tools/pipedrive/get_mail_thread.ts @@ -3,6 +3,7 @@ import type { PipedriveGetMailThreadParams, PipedriveGetMailThreadResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetMailThread') @@ -28,6 +29,13 @@ export const pipedriveGetMailThreadTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, thread_id: { type: 'string', required: true, @@ -40,16 +48,7 @@ export const pipedriveGetMailThreadTool: ToolConfig< url: (params) => `https://api.pipedrive.com/v1/mailbox/mailThreads/${params.thread_id}/mailMessages`, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/pipedrive/get_pipeline_deals.ts b/apps/sim/tools/pipedrive/get_pipeline_deals.ts index 6ffc889be97..77366b1c856 100644 --- a/apps/sim/tools/pipedrive/get_pipeline_deals.ts +++ b/apps/sim/tools/pipedrive/get_pipeline_deals.ts @@ -3,6 +3,7 @@ import type { PipedriveGetPipelineDealsParams, PipedriveGetPipelineDealsResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetPipelineDeals') @@ -23,6 +24,13 @@ export const pipedriveGetPipelineDealsTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, pipeline_id: { type: 'string', required: true, @@ -62,16 +70,7 @@ export const pipedriveGetPipelineDealsTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/pipedrive/get_pipelines.ts b/apps/sim/tools/pipedrive/get_pipelines.ts index 7f0fc2619de..0fb404146ab 100644 --- a/apps/sim/tools/pipedrive/get_pipelines.ts +++ b/apps/sim/tools/pipedrive/get_pipelines.ts @@ -4,6 +4,7 @@ import type { PipedriveGetPipelinesResponse, } from '@/tools/pipedrive/types' import { PIPEDRIVE_PIPELINE_OUTPUT_PROPERTIES } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetPipelines') @@ -24,6 +25,13 @@ export const pipedriveGetPipelinesTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, sort_by: { type: 'string', required: false, @@ -64,16 +72,7 @@ export const pipedriveGetPipelinesTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/pipedrive/get_projects.ts b/apps/sim/tools/pipedrive/get_projects.ts index 23d27d97d18..b254ecbd367 100644 --- a/apps/sim/tools/pipedrive/get_projects.ts +++ b/apps/sim/tools/pipedrive/get_projects.ts @@ -3,6 +3,7 @@ import type { PipedriveGetProjectsParams, PipedriveGetProjectsResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveGetProjects') @@ -23,6 +24,13 @@ export const pipedriveGetProjectsTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, project_id: { type: 'string', required: false, @@ -69,16 +77,7 @@ export const pipedriveGetProjectsTool: ToolConfig< return queryString ? `${baseUrl}?${queryString}` : baseUrl }, method: 'GET', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - } - }, + headers: (params) => getPipedriveAuthHeaders(params), }, transformResponse: async (response: Response, params) => { diff --git a/apps/sim/tools/pipedrive/types.ts b/apps/sim/tools/pipedrive/types.ts index bdb7fafc3b4..9bcf3536b5b 100644 --- a/apps/sim/tools/pipedrive/types.ts +++ b/apps/sim/tools/pipedrive/types.ts @@ -1,5 +1,16 @@ import type { OutputProperty, ToolFileData, ToolResponse } from '@/tools/types' +/** + * Params shared by every Pipedrive tool. `authStyle` is injected by the + * credential resolver when the credential is a token-paste service account + * whose pasted API token must be sent as `x-api-token` instead of + * `Authorization: Bearer`; it is absent for OAuth credentials. + */ +export interface PipedriveBaseParams { + accessToken: string + authStyle?: 'x-api-token' +} + /** * Output property definitions for Pipedrive API responses. * @see https://developers.pipedrive.com/docs/api/v1 @@ -357,8 +368,7 @@ interface PipedriveMailMessage { } // GET All Deals -export interface PipedriveGetAllDealsParams { - accessToken: string +export interface PipedriveGetAllDealsParams extends PipedriveBaseParams { status?: string person_id?: string org_id?: string @@ -383,8 +393,7 @@ export interface PipedriveGetAllDealsResponse extends ToolResponse { } // GET Deal -export interface PipedriveGetDealParams { - accessToken: string +export interface PipedriveGetDealParams extends PipedriveBaseParams { deal_id: string } @@ -398,8 +407,7 @@ export interface PipedriveGetDealResponse extends ToolResponse { } // CREATE Deal -export interface PipedriveCreateDealParams { - accessToken: string +export interface PipedriveCreateDealParams extends PipedriveBaseParams { title: string value?: string currency?: string @@ -421,8 +429,7 @@ export interface PipedriveCreateDealResponse extends ToolResponse { } // UPDATE Deal -export interface PipedriveUpdateDealParams { - accessToken: string +export interface PipedriveUpdateDealParams extends PipedriveBaseParams { deal_id: string title?: string value?: string @@ -441,8 +448,7 @@ export interface PipedriveUpdateDealResponse extends ToolResponse { } // GET Files -export interface PipedriveGetFilesParams { - accessToken: string +export interface PipedriveGetFilesParams extends PipedriveBaseParams { sort?: string limit?: string start?: string @@ -462,8 +468,7 @@ export interface PipedriveGetFilesResponse extends ToolResponse { output: PipedriveGetFilesOutput } -export interface PipedriveGetMailMessagesParams { - accessToken: string +export interface PipedriveGetMailMessagesParams extends PipedriveBaseParams { folder?: string limit?: string start?: string @@ -482,8 +487,7 @@ export interface PipedriveGetMailMessagesResponse extends ToolResponse { } // GET Mail Thread -export interface PipedriveGetMailThreadParams { - accessToken: string +export interface PipedriveGetMailThreadParams extends PipedriveBaseParams { thread_id: string } @@ -501,8 +505,7 @@ export interface PipedriveGetMailThreadResponse extends ToolResponse { } // GET All Pipelines -export interface PipedriveGetPipelinesParams { - accessToken: string +export interface PipedriveGetPipelinesParams extends PipedriveBaseParams { sort_by?: string sort_direction?: string limit?: string @@ -522,8 +525,7 @@ export interface PipedriveGetPipelinesResponse extends ToolResponse { } // GET Pipeline Deals -export interface PipedriveGetPipelineDealsParams { - accessToken: string +export interface PipedriveGetPipelineDealsParams extends PipedriveBaseParams { pipeline_id: string stage_id?: string limit?: string @@ -546,8 +548,7 @@ export interface PipedriveGetPipelineDealsResponse extends ToolResponse { } // GET All Projects (or single project if project_id provided) -export interface PipedriveGetProjectsParams { - accessToken: string +export interface PipedriveGetProjectsParams extends PipedriveBaseParams { project_id?: string status?: string limit?: string @@ -568,8 +569,7 @@ export interface PipedriveGetProjectsResponse extends ToolResponse { } // CREATE Project -export interface PipedriveCreateProjectParams { - accessToken: string +export interface PipedriveCreateProjectParams extends PipedriveBaseParams { title: string description?: string start_date?: string @@ -586,8 +586,7 @@ export interface PipedriveCreateProjectResponse extends ToolResponse { } // GET All Activities -export interface PipedriveGetActivitiesParams { - accessToken: string +export interface PipedriveGetActivitiesParams extends PipedriveBaseParams { user_id?: string type?: string done?: string @@ -608,8 +607,7 @@ export interface PipedriveGetActivitiesResponse extends ToolResponse { } // CREATE Activity -export interface PipedriveCreateActivityParams { - accessToken: string +export interface PipedriveCreateActivityParams extends PipedriveBaseParams { subject: string type: string due_date: string @@ -631,8 +629,7 @@ export interface PipedriveCreateActivityResponse extends ToolResponse { } // UPDATE Activity -export interface PipedriveUpdateActivityParams { - accessToken: string +export interface PipedriveUpdateActivityParams extends PipedriveBaseParams { activity_id: string subject?: string due_date?: string @@ -652,8 +649,7 @@ export interface PipedriveUpdateActivityResponse extends ToolResponse { } // GET Leads -export interface PipedriveGetLeadsParams { - accessToken: string +export interface PipedriveGetLeadsParams extends PipedriveBaseParams { lead_id?: string archived?: string owner_id?: string @@ -677,8 +673,7 @@ export interface PipedriveGetLeadsResponse extends ToolResponse { } // CREATE Lead -export interface PipedriveCreateLeadParams { - accessToken: string +export interface PipedriveCreateLeadParams extends PipedriveBaseParams { title: string person_id?: string organization_id?: string @@ -699,8 +694,7 @@ export interface PipedriveCreateLeadResponse extends ToolResponse { } // UPDATE Lead -export interface PipedriveUpdateLeadParams { - accessToken: string +export interface PipedriveUpdateLeadParams extends PipedriveBaseParams { lead_id: string title?: string person_id?: string @@ -722,8 +716,7 @@ export interface PipedriveUpdateLeadResponse extends ToolResponse { } // DELETE Lead -export interface PipedriveDeleteLeadParams { - accessToken: string +export interface PipedriveDeleteLeadParams extends PipedriveBaseParams { lead_id: string } diff --git a/apps/sim/tools/pipedrive/update_activity.ts b/apps/sim/tools/pipedrive/update_activity.ts index d193a4e7fc9..ac269a9fd28 100644 --- a/apps/sim/tools/pipedrive/update_activity.ts +++ b/apps/sim/tools/pipedrive/update_activity.ts @@ -3,6 +3,7 @@ import type { PipedriveUpdateActivityParams, PipedriveUpdateActivityResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveUpdateActivity') @@ -23,6 +24,13 @@ export const pipedriveUpdateActivityTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, activity_id: { type: 'string', required: true, @@ -70,17 +78,10 @@ export const pipedriveUpdateActivityTool: ToolConfig< request: { url: (params) => `https://api.pipedrive.com/v1/activities/${params.activity_id}`, method: 'PUT', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = {} diff --git a/apps/sim/tools/pipedrive/update_deal.ts b/apps/sim/tools/pipedrive/update_deal.ts index a24b6a77202..a97d1e54d20 100644 --- a/apps/sim/tools/pipedrive/update_deal.ts +++ b/apps/sim/tools/pipedrive/update_deal.ts @@ -3,6 +3,7 @@ import type { PipedriveUpdateDealParams, PipedriveUpdateDealResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveUpdateDeal') @@ -23,6 +24,13 @@ export const pipedriveUpdateDealTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, deal_id: { type: 'string', required: true, @@ -64,17 +72,10 @@ export const pipedriveUpdateDealTool: ToolConfig< request: { url: (params) => `https://api.pipedrive.com/api/v2/deals/${params.deal_id}`, method: 'PATCH', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = {} diff --git a/apps/sim/tools/pipedrive/update_lead.ts b/apps/sim/tools/pipedrive/update_lead.ts index 897b4a7714b..2a02c03ee4e 100644 --- a/apps/sim/tools/pipedrive/update_lead.ts +++ b/apps/sim/tools/pipedrive/update_lead.ts @@ -3,6 +3,7 @@ import type { PipedriveUpdateLeadParams, PipedriveUpdateLeadResponse, } from '@/tools/pipedrive/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('PipedriveUpdateLead') @@ -28,6 +29,13 @@ export const pipedriveUpdateLeadTool: ToolConfig< visibility: 'hidden', description: 'The access token for the Pipedrive API', }, + authStyle: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Auth scheme for the token; set by the credential resolver for API-token service accounts', + }, lead_id: { type: 'string', required: true, @@ -87,17 +95,10 @@ export const pipedriveUpdateLeadTool: ToolConfig< request: { url: (params) => `https://api.pipedrive.com/v1/leads/${params.lead_id}`, method: 'PATCH', - headers: (params) => { - if (!params.accessToken) { - throw new Error('Access token is required') - } - - return { - Authorization: `Bearer ${params.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - } - }, + headers: (params) => ({ + ...getPipedriveAuthHeaders(params), + 'Content-Type': 'application/json', + }), body: (params) => { const body: Record = {} diff --git a/apps/sim/tools/pipedrive/utils.test.ts b/apps/sim/tools/pipedrive/utils.test.ts new file mode 100644 index 00000000000..89e103721f4 --- /dev/null +++ b/apps/sim/tools/pipedrive/utils.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' + +describe('getPipedriveAuthHeaders', () => { + it('returns Bearer headers by default (OAuth credentials)', () => { + expect(getPipedriveAuthHeaders({ accessToken: 'oauth-token' })).toEqual({ + Authorization: 'Bearer oauth-token', + Accept: 'application/json', + }) + }) + + it('returns x-api-token headers for API-token service accounts', () => { + expect(getPipedriveAuthHeaders({ accessToken: 'api-token', authStyle: 'x-api-token' })).toEqual( + { + 'x-api-token': 'api-token', + Accept: 'application/json', + } + ) + }) + + it('ignores unknown auth styles and falls back to Bearer', () => { + const params = { accessToken: 'tok', authStyle: 'bearer' } as unknown as Parameters< + typeof getPipedriveAuthHeaders + >[0] + expect(getPipedriveAuthHeaders(params)).toEqual({ + Authorization: 'Bearer tok', + Accept: 'application/json', + }) + }) + + it('throws when the access token is missing', () => { + expect(() => getPipedriveAuthHeaders({ accessToken: '' })).toThrow('Access token is required') + }) +}) diff --git a/apps/sim/tools/pipedrive/utils.ts b/apps/sim/tools/pipedrive/utils.ts new file mode 100644 index 00000000000..fd08dfc4fce --- /dev/null +++ b/apps/sim/tools/pipedrive/utils.ts @@ -0,0 +1,25 @@ +import type { PipedriveBaseParams } from '@/tools/pipedrive/types' + +/** + * Builds the auth headers for a Pipedrive API request. OAuth access tokens use + * `Authorization: Bearer`; pasted personal API tokens (token-paste service + * accounts) must use the `x-api-token` header instead — Pipedrive documents no + * token-format discriminator, so the credential resolver threads an explicit + * `authStyle` signal through the token route into tool params. Works on both + * `/v1` and `/api/v2` endpoints. + */ +export function getPipedriveAuthHeaders(params: PipedriveBaseParams): Record { + if (!params.accessToken) { + throw new Error('Access token is required') + } + if (params.authStyle === 'x-api-token') { + return { + 'x-api-token': params.accessToken, + Accept: 'application/json', + } + } + return { + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + } +} diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index 6667a326f8f..86293526c22 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -1,4 +1,30 @@ import { functionExecuteTool } from '@/tools/function' +import { + gmailAddLabelV2Tool, + gmailArchiveV2Tool, + gmailCreateLabelV2Tool, + gmailDeleteDraftV2Tool, + gmailDeleteLabelV2Tool, + gmailDeleteV2Tool, + gmailDraftV2Tool, + gmailEditDraftV2Tool, + gmailGetDraftV2Tool, + gmailGetThreadV2Tool, + gmailListDraftsV2Tool, + gmailListLabelsV2Tool, + gmailListThreadsV2Tool, + gmailMarkReadV2Tool, + gmailMarkUnreadV2Tool, + gmailMoveV2Tool, + gmailReadV2Tool, + gmailRemoveLabelV2Tool, + gmailSearchV2Tool, + gmailSendV2Tool, + gmailTrashThreadV2Tool, + gmailUnarchiveV2Tool, + gmailUntrashThreadV2Tool, + gmailUpdateLabelV2Tool, +} from '@/tools/gmail' import { httpRequestTool } from '@/tools/http' import { slackAddReactionTool, @@ -52,12 +78,37 @@ import type { ToolConfig } from '@/tools/types' * next.config.ts) so the local dev server never compiles the full ~247-tool * graph (~2,074 modules) that the shared workspace layout otherwise drags into * every route. Only these tools execute in minimal mode; unset the flag for the - * full set. Slack tools are included so the Slack block (action + native trigger) - * works end-to-end in minimal dev. NEVER aliased in production. + * full set. The slack and gmail v2 sets back the `slack`/`slack_v2` and + * `gmail_v2` blocks kept in `blocks/registry-maps.minimal.ts`. NEVER aliased in + * production. */ export const tools: Record = { http_request: httpRequestTool, function_execute: functionExecuteTool, + gmail_send_v2: gmailSendV2Tool, + gmail_read_v2: gmailReadV2Tool, + gmail_search_v2: gmailSearchV2Tool, + gmail_draft_v2: gmailDraftV2Tool, + gmail_move_v2: gmailMoveV2Tool, + gmail_mark_read_v2: gmailMarkReadV2Tool, + gmail_mark_unread_v2: gmailMarkUnreadV2Tool, + gmail_archive_v2: gmailArchiveV2Tool, + gmail_unarchive_v2: gmailUnarchiveV2Tool, + gmail_delete_v2: gmailDeleteV2Tool, + gmail_add_label_v2: gmailAddLabelV2Tool, + gmail_remove_label_v2: gmailRemoveLabelV2Tool, + gmail_create_label_v2: gmailCreateLabelV2Tool, + gmail_delete_draft_v2: gmailDeleteDraftV2Tool, + gmail_delete_label_v2: gmailDeleteLabelV2Tool, + gmail_edit_draft_v2: gmailEditDraftV2Tool, + gmail_get_draft_v2: gmailGetDraftV2Tool, + gmail_get_thread_v2: gmailGetThreadV2Tool, + gmail_list_drafts_v2: gmailListDraftsV2Tool, + gmail_list_labels_v2: gmailListLabelsV2Tool, + gmail_list_threads_v2: gmailListThreadsV2Tool, + gmail_trash_thread_v2: gmailTrashThreadV2Tool, + gmail_untrash_thread_v2: gmailUntrashThreadV2Tool, + gmail_update_label_v2: gmailUpdateLabelV2Tool, slack_add_reaction: slackAddReactionTool, slack_archive_conversation: slackArchiveConversationTool, slack_canvas: slackCanvasTool, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4d84db3425e..3197c49b9bf 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -501,6 +501,46 @@ import { clickhouseTruncateTableTool, clickhouseUpdateTool, } from '@/tools/clickhouse' +import { + clickupAddTagToTaskTool, + clickupCreateChecklistItemTool, + clickupCreateChecklistTool, + clickupCreateCommentTool, + clickupCreateFolderTool, + clickupCreateListTool, + clickupCreateTaskTool, + clickupCreateTimeEntryTool, + clickupDeleteChecklistItemTool, + clickupDeleteChecklistTool, + clickupDeleteCommentTool, + clickupDeleteTaskTool, + clickupDeleteTimeEntryTool, + clickupGetCommentsTool, + clickupGetCustomFieldsTool, + clickupGetFoldersTool, + clickupGetListMembersTool, + clickupGetListsTool, + clickupGetRunningTimerTool, + clickupGetSpacesTool, + clickupGetSpaceTagsTool, + clickupGetTaskMembersTool, + clickupGetTasksTool, + clickupGetTaskTool, + clickupGetTimeEntriesTool, + clickupGetWorkspacesTool, + clickupRemoveCustomFieldValueTool, + clickupRemoveTagFromTaskTool, + clickupSearchTasksTool, + clickupSetCustomFieldValueTool, + clickupStartTimerTool, + clickupStopTimerTool, + clickupUpdateChecklistItemTool, + clickupUpdateChecklistTool, + clickupUpdateCommentTool, + clickupUpdateTaskTool, + clickupUpdateTimeEntryTool, + clickupUploadAttachmentTool, +} from '@/tools/clickup' import { cloudflareCreateDnsRecordTool, cloudflareCreateZoneTool, @@ -1222,7 +1262,14 @@ import { githubUpdateReleaseV2Tool, } from '@/tools/github' import { + gitlabActivateUserTool, + gitlabAddMemberTool, + gitlabAddSamlGroupLinkTool, + gitlabApproveAccessRequestTool, gitlabApproveMergeRequestTool, + gitlabApproveUserTool, + gitlabBanUserTool, + gitlabBlockUserTool, gitlabCancelPipelineTool, gitlabCompareBranchesTool, gitlabCreateBranchTool, @@ -1233,8 +1280,14 @@ import { gitlabCreateMergeRequestTool, gitlabCreatePipelineTool, gitlabCreateReleaseTool, + gitlabCreateUserTool, + gitlabDeactivateUserTool, gitlabDeleteBranchTool, gitlabDeleteIssueTool, + gitlabDeleteSamlGroupLinkTool, + gitlabDeleteUserIdentityTool, + gitlabDeleteUserTool, + gitlabDenyAccessRequestTool, gitlabGetFileTool, gitlabGetIssueTool, gitlabGetJobLogTool, @@ -1242,21 +1295,35 @@ import { gitlabGetMergeRequestTool, gitlabGetPipelineTool, gitlabGetProjectTool, + gitlabInviteMemberTool, + gitlabListAccessRequestsTool, gitlabListBranchesTool, gitlabListCommitsTool, + gitlabListInvitationsTool, gitlabListIssuesTool, + gitlabListMembersTool, gitlabListMergeRequestsTool, gitlabListPipelineJobsTool, gitlabListPipelinesTool, gitlabListProjectsTool, gitlabListReleasesTool, gitlabListRepositoryTreeTool, + gitlabListSamlGroupLinksTool, gitlabMergeMergeRequestTool, gitlabPlayJobTool, + gitlabRejectUserTool, + gitlabRemoveMemberTool, gitlabRetryPipelineTool, + gitlabRevokeInvitationTool, + gitlabSearchUsersTool, + gitlabUnbanUserTool, + gitlabUnblockUserTool, gitlabUpdateFileTool, + gitlabUpdateInvitationTool, gitlabUpdateIssueTool, + gitlabUpdateMemberTool, gitlabUpdateMergeRequestTool, + gitlabUpdateUserTool, } from '@/tools/gitlab' import { gmailAddLabelTool, @@ -1636,10 +1703,13 @@ import { } from '@/tools/grafana' import { grainCreateHookTool, + grainCreateHookV2Tool, grainDeleteHookTool, + grainDeleteHookV2Tool, grainGetRecordingTool, grainGetTranscriptTool, grainListHooksTool, + grainListHooksV2Tool, grainListMeetingTypesTool, grainListRecordingsTool, grainListTeamsTool, @@ -3121,6 +3191,72 @@ import { ripplingUpdateTitleTool, ripplingUpdateWorkLocationTool, } from '@/tools/rippling' +import { + rocketlaneAddFieldOptionTool, + rocketlaneAddProjectMembersTool, + rocketlaneAddTaskAssigneesTool, + rocketlaneAddTaskDependenciesTool, + rocketlaneAddTaskFollowersTool, + rocketlaneArchiveProjectTool, + rocketlaneAssignPlaceholdersTool, + rocketlaneCreateFieldTool, + rocketlaneCreatePhaseTool, + rocketlaneCreateProjectTool, + rocketlaneCreateSpaceDocumentTool, + rocketlaneCreateSpaceTool, + rocketlaneCreateTaskTool, + rocketlaneCreateTimeEntryTool, + rocketlaneCreateTimeOffTool, + rocketlaneDeleteFieldTool, + rocketlaneDeletePhaseTool, + rocketlaneDeleteProjectTool, + rocketlaneDeleteSpaceDocumentTool, + rocketlaneDeleteSpaceTool, + rocketlaneDeleteTaskTool, + rocketlaneDeleteTimeEntryTool, + rocketlaneDeleteTimeOffTool, + rocketlaneGetFieldTool, + rocketlaneGetInvoiceLineItemsTool, + rocketlaneGetInvoicePaymentsTool, + rocketlaneGetInvoiceTool, + rocketlaneGetPhaseTool, + rocketlaneGetProjectTool, + rocketlaneGetSpaceDocumentTool, + rocketlaneGetSpaceTool, + rocketlaneGetTaskTool, + rocketlaneGetTimeEntryTool, + rocketlaneGetTimeOffTool, + rocketlaneGetUserTool, + rocketlaneImportTemplateTool, + rocketlaneListFieldsTool, + rocketlaneListInvoicesTool, + rocketlaneListPhasesTool, + rocketlaneListPlaceholdersTool, + rocketlaneListProjectsTool, + rocketlaneListResourceAllocationsTool, + rocketlaneListSpaceDocumentsTool, + rocketlaneListSpacesTool, + rocketlaneListTasksTool, + rocketlaneListTimeEntriesTool, + rocketlaneListTimeEntryCategoriesTool, + rocketlaneListTimeOffsTool, + rocketlaneListUsersTool, + rocketlaneMoveTaskToPhaseTool, + rocketlaneRemoveProjectMembersTool, + rocketlaneRemoveTaskAssigneesTool, + rocketlaneRemoveTaskDependenciesTool, + rocketlaneRemoveTaskFollowersTool, + rocketlaneSearchTimeEntriesTool, + rocketlaneUnassignPlaceholdersTool, + rocketlaneUpdateFieldOptionTool, + rocketlaneUpdateFieldTool, + rocketlaneUpdatePhaseTool, + rocketlaneUpdateProjectTool, + rocketlaneUpdateSpaceDocumentTool, + rocketlaneUpdateSpaceTool, + rocketlaneUpdateTaskTool, + rocketlaneUpdateTimeEntryTool, +} from '@/tools/rocketlane' import { rootlyAcknowledgeAlertTool, rootlyAddIncidentEventTool, @@ -6224,7 +6360,14 @@ export const tools: Record = { github_check_star_v2: githubCheckStarV2Tool, github_list_stargazers: githubListStargazersTool, github_list_stargazers_v2: githubListStargazersV2Tool, + gitlab_activate_user: gitlabActivateUserTool, + gitlab_add_member: gitlabAddMemberTool, + gitlab_add_saml_group_link: gitlabAddSamlGroupLinkTool, + gitlab_approve_access_request: gitlabApproveAccessRequestTool, gitlab_approve_merge_request: gitlabApproveMergeRequestTool, + gitlab_approve_user: gitlabApproveUserTool, + gitlab_ban_user: gitlabBanUserTool, + gitlab_block_user: gitlabBlockUserTool, gitlab_cancel_pipeline: gitlabCancelPipelineTool, gitlab_compare_branches: gitlabCompareBranchesTool, gitlab_create_branch: gitlabCreateBranchTool, @@ -6235,8 +6378,14 @@ export const tools: Record = { gitlab_create_merge_request_note: gitlabCreateMergeRequestNoteTool, gitlab_create_pipeline: gitlabCreatePipelineTool, gitlab_create_release: gitlabCreateReleaseTool, + gitlab_create_user: gitlabCreateUserTool, + gitlab_deactivate_user: gitlabDeactivateUserTool, gitlab_delete_branch: gitlabDeleteBranchTool, gitlab_delete_issue: gitlabDeleteIssueTool, + gitlab_delete_saml_group_link: gitlabDeleteSamlGroupLinkTool, + gitlab_delete_user: gitlabDeleteUserTool, + gitlab_delete_user_identity: gitlabDeleteUserIdentityTool, + gitlab_deny_access_request: gitlabDenyAccessRequestTool, gitlab_get_file: gitlabGetFileTool, gitlab_get_issue: gitlabGetIssueTool, gitlab_get_job_log: gitlabGetJobLogTool, @@ -6244,21 +6393,35 @@ export const tools: Record = { gitlab_get_merge_request_changes: gitlabGetMergeRequestChangesTool, gitlab_get_pipeline: gitlabGetPipelineTool, gitlab_get_project: gitlabGetProjectTool, + gitlab_invite_member: gitlabInviteMemberTool, + gitlab_list_access_requests: gitlabListAccessRequestsTool, gitlab_list_branches: gitlabListBranchesTool, gitlab_list_commits: gitlabListCommitsTool, + gitlab_list_invitations: gitlabListInvitationsTool, gitlab_list_issues: gitlabListIssuesTool, + gitlab_list_members: gitlabListMembersTool, gitlab_list_merge_requests: gitlabListMergeRequestsTool, gitlab_list_pipeline_jobs: gitlabListPipelineJobsTool, gitlab_list_pipelines: gitlabListPipelinesTool, gitlab_list_projects: gitlabListProjectsTool, gitlab_list_releases: gitlabListReleasesTool, gitlab_list_repository_tree: gitlabListRepositoryTreeTool, + gitlab_list_saml_group_links: gitlabListSamlGroupLinksTool, gitlab_merge_merge_request: gitlabMergeMergeRequestTool, gitlab_play_job: gitlabPlayJobTool, + gitlab_reject_user: gitlabRejectUserTool, + gitlab_remove_member: gitlabRemoveMemberTool, gitlab_retry_pipeline: gitlabRetryPipelineTool, + gitlab_revoke_invitation: gitlabRevokeInvitationTool, + gitlab_search_users: gitlabSearchUsersTool, + gitlab_unban_user: gitlabUnbanUserTool, + gitlab_unblock_user: gitlabUnblockUserTool, gitlab_update_file: gitlabUpdateFileTool, + gitlab_update_invitation: gitlabUpdateInvitationTool, gitlab_update_issue: gitlabUpdateIssueTool, + gitlab_update_member: gitlabUpdateMemberTool, gitlab_update_merge_request: gitlabUpdateMergeRequestTool, + gitlab_update_user: gitlabUpdateUserTool, grain_list_recordings: grainListRecordingsTool, grain_get_recording: grainGetRecordingTool, grain_get_transcript: grainGetTranscriptTool, @@ -6266,8 +6429,11 @@ export const tools: Record = { grain_list_meeting_types: grainListMeetingTypesTool, grain_list_views: grainListViewsTool, grain_create_hook: grainCreateHookTool, + grain_create_hook_v2: grainCreateHookV2Tool, grain_list_hooks: grainListHooksTool, + grain_list_hooks_v2: grainListHooksV2Tool, grain_delete_hook: grainDeleteHookTool, + grain_delete_hook_v2: grainDeleteHookV2Tool, greptile_query: greptileQueryTool, greptile_search: greptileSearchTool, greptile_index_repo: greptileIndexRepoTool, @@ -6523,6 +6689,70 @@ export const tools: Record = { rippling_update_supergroup_inclusion_members: ripplingUpdateSupergroupInclusionMembersTool, rippling_update_title: ripplingUpdateTitleTool, rippling_update_work_location: ripplingUpdateWorkLocationTool, + rocketlane_add_field_option: rocketlaneAddFieldOptionTool, + rocketlane_add_project_members: rocketlaneAddProjectMembersTool, + rocketlane_add_task_assignees: rocketlaneAddTaskAssigneesTool, + rocketlane_add_task_dependencies: rocketlaneAddTaskDependenciesTool, + rocketlane_add_task_followers: rocketlaneAddTaskFollowersTool, + rocketlane_archive_project: rocketlaneArchiveProjectTool, + rocketlane_assign_placeholders: rocketlaneAssignPlaceholdersTool, + rocketlane_create_field: rocketlaneCreateFieldTool, + rocketlane_create_phase: rocketlaneCreatePhaseTool, + rocketlane_create_project: rocketlaneCreateProjectTool, + rocketlane_create_space: rocketlaneCreateSpaceTool, + rocketlane_create_space_document: rocketlaneCreateSpaceDocumentTool, + rocketlane_create_task: rocketlaneCreateTaskTool, + rocketlane_create_time_entry: rocketlaneCreateTimeEntryTool, + rocketlane_create_time_off: rocketlaneCreateTimeOffTool, + rocketlane_delete_field: rocketlaneDeleteFieldTool, + rocketlane_delete_phase: rocketlaneDeletePhaseTool, + rocketlane_delete_project: rocketlaneDeleteProjectTool, + rocketlane_delete_space: rocketlaneDeleteSpaceTool, + rocketlane_delete_space_document: rocketlaneDeleteSpaceDocumentTool, + rocketlane_delete_task: rocketlaneDeleteTaskTool, + rocketlane_delete_time_entry: rocketlaneDeleteTimeEntryTool, + rocketlane_delete_time_off: rocketlaneDeleteTimeOffTool, + rocketlane_get_field: rocketlaneGetFieldTool, + rocketlane_get_invoice: rocketlaneGetInvoiceTool, + rocketlane_get_invoice_line_items: rocketlaneGetInvoiceLineItemsTool, + rocketlane_get_invoice_payments: rocketlaneGetInvoicePaymentsTool, + rocketlane_get_phase: rocketlaneGetPhaseTool, + rocketlane_get_project: rocketlaneGetProjectTool, + rocketlane_get_space: rocketlaneGetSpaceTool, + rocketlane_get_space_document: rocketlaneGetSpaceDocumentTool, + rocketlane_get_task: rocketlaneGetTaskTool, + rocketlane_get_time_entry: rocketlaneGetTimeEntryTool, + rocketlane_get_time_off: rocketlaneGetTimeOffTool, + rocketlane_get_user: rocketlaneGetUserTool, + rocketlane_import_template: rocketlaneImportTemplateTool, + rocketlane_list_fields: rocketlaneListFieldsTool, + rocketlane_list_invoices: rocketlaneListInvoicesTool, + rocketlane_list_phases: rocketlaneListPhasesTool, + rocketlane_list_placeholders: rocketlaneListPlaceholdersTool, + rocketlane_list_projects: rocketlaneListProjectsTool, + rocketlane_list_resource_allocations: rocketlaneListResourceAllocationsTool, + rocketlane_list_space_documents: rocketlaneListSpaceDocumentsTool, + rocketlane_list_spaces: rocketlaneListSpacesTool, + rocketlane_list_tasks: rocketlaneListTasksTool, + rocketlane_list_time_entries: rocketlaneListTimeEntriesTool, + rocketlane_list_time_entry_categories: rocketlaneListTimeEntryCategoriesTool, + rocketlane_list_time_offs: rocketlaneListTimeOffsTool, + rocketlane_list_users: rocketlaneListUsersTool, + rocketlane_move_task_to_phase: rocketlaneMoveTaskToPhaseTool, + rocketlane_remove_project_members: rocketlaneRemoveProjectMembersTool, + rocketlane_remove_task_assignees: rocketlaneRemoveTaskAssigneesTool, + rocketlane_remove_task_dependencies: rocketlaneRemoveTaskDependenciesTool, + rocketlane_remove_task_followers: rocketlaneRemoveTaskFollowersTool, + rocketlane_search_time_entries: rocketlaneSearchTimeEntriesTool, + rocketlane_unassign_placeholders: rocketlaneUnassignPlaceholdersTool, + rocketlane_update_field: rocketlaneUpdateFieldTool, + rocketlane_update_field_option: rocketlaneUpdateFieldOptionTool, + rocketlane_update_phase: rocketlaneUpdatePhaseTool, + rocketlane_update_project: rocketlaneUpdateProjectTool, + rocketlane_update_space: rocketlaneUpdateSpaceTool, + rocketlane_update_space_document: rocketlaneUpdateSpaceDocumentTool, + rocketlane_update_task: rocketlaneUpdateTaskTool, + rocketlane_update_time_entry: rocketlaneUpdateTimeEntryTool, rootly_acknowledge_alert: rootlyAcknowledgeAlertTool, rootly_add_incident_event: rootlyAddIncidentEventTool, rootly_add_subscribers: rootlyAddSubscribersTool, @@ -7530,6 +7760,44 @@ export const tools: Record = { clerk_get_jwt_template: clerkGetJwtTemplateTool, clerk_create_actor_token: clerkCreateActorTokenTool, clerk_revoke_actor_token: clerkRevokeActorTokenTool, + clickup_create_task: clickupCreateTaskTool, + clickup_get_task: clickupGetTaskTool, + clickup_update_task: clickupUpdateTaskTool, + clickup_delete_task: clickupDeleteTaskTool, + clickup_get_tasks: clickupGetTasksTool, + clickup_search_tasks: clickupSearchTasksTool, + clickup_create_comment: clickupCreateCommentTool, + clickup_get_comments: clickupGetCommentsTool, + clickup_update_comment: clickupUpdateCommentTool, + clickup_delete_comment: clickupDeleteCommentTool, + clickup_upload_attachment: clickupUploadAttachmentTool, + clickup_add_tag_to_task: clickupAddTagToTaskTool, + clickup_remove_tag_from_task: clickupRemoveTagFromTaskTool, + clickup_get_space_tags: clickupGetSpaceTagsTool, + clickup_get_task_members: clickupGetTaskMembersTool, + clickup_get_list_members: clickupGetListMembersTool, + clickup_get_custom_fields: clickupGetCustomFieldsTool, + clickup_get_workspaces: clickupGetWorkspacesTool, + clickup_get_spaces: clickupGetSpacesTool, + clickup_get_folders: clickupGetFoldersTool, + clickup_get_lists: clickupGetListsTool, + clickup_create_folder: clickupCreateFolderTool, + clickup_create_list: clickupCreateListTool, + clickup_set_custom_field_value: clickupSetCustomFieldValueTool, + clickup_remove_custom_field_value: clickupRemoveCustomFieldValueTool, + clickup_create_checklist: clickupCreateChecklistTool, + clickup_update_checklist: clickupUpdateChecklistTool, + clickup_delete_checklist: clickupDeleteChecklistTool, + clickup_create_checklist_item: clickupCreateChecklistItemTool, + clickup_update_checklist_item: clickupUpdateChecklistItemTool, + clickup_delete_checklist_item: clickupDeleteChecklistItemTool, + clickup_get_time_entries: clickupGetTimeEntriesTool, + clickup_create_time_entry: clickupCreateTimeEntryTool, + clickup_update_time_entry: clickupUpdateTimeEntryTool, + clickup_delete_time_entry: clickupDeleteTimeEntryTool, + clickup_start_timer: clickupStartTimerTool, + clickup_stop_timer: clickupStopTimerTool, + clickup_get_running_timer: clickupGetRunningTimerTool, cloudflare_list_zones: cloudflareListZonesTool, cloudflare_get_zone: cloudflareGetZoneTool, cloudflare_create_zone: cloudflareCreateZoneTool, diff --git a/apps/sim/tools/rocketlane/add_field_option.ts b/apps/sim/tools/rocketlane/add_field_option.ts new file mode 100644 index 00000000000..a8ff193246a --- /dev/null +++ b/apps/sim/tools/rocketlane/add_field_option.ts @@ -0,0 +1,78 @@ +import { + FIELD_OPTION_OUTPUT_PROPERTIES, + mapFieldOption, + ROCKETLANE_API_BASE, + type RocketlaneAddFieldOptionParams, + type RocketlaneFieldOptionResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAddFieldOptionTool: ToolConfig< + RocketlaneAddFieldOptionParams, + RocketlaneFieldOptionResponse +> = { + id: 'rocketlane_add_field_option', + name: 'Rocketlane Add Field Option', + description: 'Add a new option to an existing SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the field to add the option to', + }, + optionLabel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Display label of the new option', + }, + optionColor: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Color of the new option (RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY)', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/fields/${encodeURIComponent(String(params.fieldId))}/add-option`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + optionLabel: params.optionLabel, + optionColor: params.optionColor, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { option: mapFieldOption(data) }, + } + }, + + outputs: { + option: { + type: 'object', + description: 'The created field option', + properties: FIELD_OPTION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/add_project_members.ts b/apps/sim/tools/rocketlane/add_project_members.ts new file mode 100644 index 00000000000..bf006e1af16 --- /dev/null +++ b/apps/sim/tools/rocketlane/add_project_members.ts @@ -0,0 +1,103 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneAddProjectMembersParams, + type RocketlaneProjectResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAddProjectMembersTool: ToolConfig< + RocketlaneAddProjectMembersParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_add_project_members', + name: 'Rocketlane Add Project Members', + description: 'Add team members and customer stakeholders to a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of team members from your organization to add', + items: { type: 'number', description: 'User ID of a team member' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Emails of team members from your organization to add', + items: { type: 'string', description: 'Email of a team member' }, + }, + customerUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of customer stakeholders to add', + items: { type: 'number', description: 'User ID of a customer' }, + }, + customerEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Emails of customer stakeholders to add', + items: { type: 'string', description: 'Email of a customer' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/add-members`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = {} + const members = [ + ...(params.memberUserIds ?? []).map((userId) => ({ userId })), + ...(params.memberEmailIds ?? []).map((emailId) => ({ emailId })), + ] + if (members.length > 0) body.members = members + const customers = [ + ...(params.customerUserIds ?? []).map((userId) => ({ userId })), + ...(params.customerEmailIds ?? []).map((emailId) => ({ emailId })), + ] + if (customers.length > 0) body.customers = customers + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The project with its updated team members', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/add_task_assignees.ts b/apps/sim/tools/rocketlane/add_task_assignees.ts new file mode 100644 index 00000000000..91aeb777bfe --- /dev/null +++ b/apps/sim/tools/rocketlane/add_task_assignees.ts @@ -0,0 +1,79 @@ +import { + buildTaskMembers, + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskAssigneesParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAddTaskAssigneesTool: ToolConfig< + RocketlaneTaskAssigneesParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_add_task_assignees', + name: 'Rocketlane Add Task Assignees', + description: 'Add members as assignees to a Rocketlane task', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to add assignees to', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to add as assignees', + items: { type: 'number' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to add as assignees', + items: { type: 'string' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/add-assignees`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + members: buildTaskMembers(params.memberUserIds, params.memberEmailIds), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated assignees', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/add_task_dependencies.ts b/apps/sim/tools/rocketlane/add_task_dependencies.ts new file mode 100644 index 00000000000..4c90db40751 --- /dev/null +++ b/apps/sim/tools/rocketlane/add_task_dependencies.ts @@ -0,0 +1,71 @@ +import { + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskDependenciesParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAddTaskDependenciesTool: ToolConfig< + RocketlaneTaskDependenciesParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_add_task_dependencies', + name: 'Rocketlane Add Task Dependencies', + description: 'Add finish-to-start dependencies between a Rocketlane task and other tasks', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to add dependencies to', + }, + dependencyTaskIds: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Task IDs the task should depend on', + items: { type: 'number' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/add-dependencies`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + dependencies: params.dependencyTaskIds.map((taskId) => ({ taskId })), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated dependencies', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/add_task_followers.ts b/apps/sim/tools/rocketlane/add_task_followers.ts new file mode 100644 index 00000000000..7c2aef02879 --- /dev/null +++ b/apps/sim/tools/rocketlane/add_task_followers.ts @@ -0,0 +1,79 @@ +import { + buildTaskMembers, + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskFollowersParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAddTaskFollowersTool: ToolConfig< + RocketlaneTaskFollowersParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_add_task_followers', + name: 'Rocketlane Add Task Followers', + description: 'Add members as followers to a Rocketlane task', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to add followers to', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to add as followers', + items: { type: 'number' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to add as followers', + items: { type: 'string' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/add-followers`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + members: buildTaskMembers(params.memberUserIds, params.memberEmailIds), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated followers', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/archive_project.ts b/apps/sim/tools/rocketlane/archive_project.ts new file mode 100644 index 00000000000..24db4bdb472 --- /dev/null +++ b/apps/sim/tools/rocketlane/archive_project.ts @@ -0,0 +1,60 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneArchiveProjectParams, + type RocketlaneProjectArchiveResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneArchiveProjectTool: ToolConfig< + RocketlaneArchiveProjectParams, + RocketlaneProjectArchiveResponse +> = { + id: 'rocketlane_archive_project', + name: 'Rocketlane Archive Project', + description: + 'Archive a Rocketlane project by ID, making it dormant while preserving its details and history', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project to archive', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/archive`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneArchiveProjectParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { archived: true, projectId: params?.projectId ?? null }, + } + }, + + outputs: { + archived: { type: 'boolean', description: 'Whether the project was archived' }, + projectId: { + type: 'number', + description: 'Unique identifier of the archived project', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/assign_placeholders.ts b/apps/sim/tools/rocketlane/assign_placeholders.ts new file mode 100644 index 00000000000..4ef173a5e3f --- /dev/null +++ b/apps/sim/tools/rocketlane/assign_placeholders.ts @@ -0,0 +1,102 @@ +import { + mapPlaceholderMapping, + mapProject, + PLACEHOLDER_MAPPING_OUTPUT_PROPERTIES, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneAssignPlaceholdersParams, + type RocketlaneProjectPlaceholdersResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneAssignPlaceholdersTool: ToolConfig< + RocketlaneAssignPlaceholdersParams, + RocketlaneProjectPlaceholdersResponse +> = { + id: 'rocketlane_assign_placeholders', + name: 'Rocketlane Assign Placeholders', + description: 'Assign a placeholder in a Rocketlane project to a user', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + placeholderId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the placeholder to assign', + }, + userId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'User ID of the project member to assign (either userId or userEmailId must be provided; must be a customer user for CUSTOMER placeholders)', + }, + userEmailId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Email of the project member to assign (either userId or userEmailId must be provided)', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/assign-placeholders`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + if (params.userId == null && !params.userEmailId) { + throw new Error('Either a user ID or a user email is required to assign a placeholder') + } + const user: Record = {} + if (params.userId != null) user.userId = params.userId + if (params.userEmailId) user.emailId = params.userEmailId + return [{ placeholderId: params.placeholderId, user }] + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { + project: mapProject(data), + placeholders: Array.isArray(data?.placeholders) + ? data.placeholders.map(mapPlaceholderMapping) + : [], + }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The project after the placeholder assignment', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + placeholders: { + type: 'array', + description: 'Placeholder-to-user mappings on the project', + items: { type: 'object', properties: PLACEHOLDER_MAPPING_OUTPUT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_field.ts b/apps/sim/tools/rocketlane/create_field.ts new file mode 100644 index 00000000000..68cd4ebb602 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_field.ts @@ -0,0 +1,146 @@ +import { + FIELD_OUTPUT_PROPERTIES, + mapField, + ROCKETLANE_API_BASE, + type RocketlaneCreateFieldParams, + type RocketlaneFieldResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateFieldTool: ToolConfig< + RocketlaneCreateFieldParams, + RocketlaneFieldResponse +> = { + id: 'rocketlane_create_field', + name: 'Rocketlane Create Field', + description: + 'Create a custom field in your Rocketlane account, with options for SINGLE_CHOICE and MULTIPLE_CHOICE field types', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldLabel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the field', + }, + fieldType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Type of the field (TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING)', + }, + objectType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Object the field is associated with (PROJECT, TASK, or USER)', + }, + fieldDescription: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the field', + }, + fieldOptions: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Options for SINGLE_CHOICE and MULTIPLE_CHOICE fields; the order provided is preserved', + items: { + type: 'object', + properties: { + optionLabel: { type: 'string', description: 'Display label of the option' }, + optionColor: { + type: 'string', + description: + 'Color of the option (RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY)', + }, + }, + }, + }, + ratingScale: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of stars for RATING fields (THREE, FIVE, SEVEN, TEN)', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the field is enabled (defaults to true)', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the field is private', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra field properties to include in the response (supported: options)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all field properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/fields`) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + fieldLabel: params.fieldLabel, + fieldType: params.fieldType, + objectType: params.objectType, + ...(params.fieldDescription != null && { fieldDescription: params.fieldDescription }), + ...(params.fieldOptions != null && { fieldOptions: params.fieldOptions }), + ...(params.ratingScale != null && { ratingScale: params.ratingScale }), + ...(params.enabled != null && { enabled: params.enabled }), + ...(params.private != null && { private: params.private }), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { field: mapField(data) }, + } + }, + + outputs: { + field: { + type: 'object', + description: 'The created field', + properties: FIELD_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_phase.ts b/apps/sim/tools/rocketlane/create_phase.ts new file mode 100644 index 00000000000..3e427918905 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_phase.ts @@ -0,0 +1,118 @@ +import { + mapPhase, + PHASE_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneCreatePhaseParams, + type RocketlanePhaseResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreatePhaseTool: ToolConfig< + RocketlaneCreatePhaseParams, + RocketlanePhaseResponse +> = { + id: 'rocketlane_create_phase', + name: 'Rocketlane Create Phase', + description: 'Create a new phase in a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + phaseName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the phase', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project to create the phase in', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Planned start date of the phase (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Planned due date of the phase (YYYY-MM-DD)', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Numeric status value to set on the phase', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the phase is private', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra phase properties to include in the response (supported: startDateActual, dueDateActual)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all phase properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/phases`) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + phaseName: params.phaseName, + project: { projectId: params.projectId }, + startDate: params.startDate, + dueDate: params.dueDate, + ...(params.statusValue != null && { status: { value: params.statusValue } }), + ...(params.private != null && { private: params.private }), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { phase: mapPhase(data) }, + } + }, + + outputs: { + phase: { + type: 'object', + description: 'The created phase', + properties: PHASE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_project.ts b/apps/sim/tools/rocketlane/create_project.ts new file mode 100644 index 00000000000..c1dcb745062 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_project.ts @@ -0,0 +1,366 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneCreateProjectParams, + type RocketlaneProjectResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateProjectTool: ToolConfig< + RocketlaneCreateProjectParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_create_project', + name: 'Rocketlane Create Project', + description: + 'Create a new Rocketlane project with a customer, owner, dates, team members, templates, financials, and custom fields', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the project', + }, + customerCompanyName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Name of the customer company (case-sensitive exact match; cannot be changed after creation)', + }, + ownerUserId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'User ID of the project owner (either ownerUserId or ownerEmailId must be provided)', + }, + ownerEmailId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Email of the project owner (either ownerUserId or ownerEmailId must be provided)', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Date on which the project begins (YYYY-MM-DD); required when sources are provided', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Date on which the project is planned to complete (YYYY-MM-DD, on or after startDate)', + }, + visibility: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Who can see the project: EVERYONE or MEMBERS', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Value (identifier) of the project status', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of team members from your organization to add to the project', + items: { type: 'number', description: 'User ID of a team member' }, + }, + customerUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of customer stakeholders to add to the project', + items: { type: 'number', description: 'User ID of a customer' }, + }, + customerChampionUserId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'User ID of the customer champion', + }, + fields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Custom field assignments, each with fieldId and fieldValue (string, number, or number array matching the field type)', + items: { + type: 'object', + description: 'Custom field assignment', + properties: { + fieldId: { type: 'number', description: 'Unique identifier of the field' }, + fieldValue: { + type: 'string', + description: 'Value of the field (string, number, or number array)', + }, + }, + }, + }, + sources: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Project templates to import at creation, each with templateId, startDate (YYYY-MM-DD), and optional prefix', + items: { + type: 'object', + description: 'Template source', + properties: { + templateId: { type: 'number', description: 'Unique identifier of the template' }, + startDate: { type: 'string', description: 'Date the template takes effect (YYYY-MM-DD)' }, + prefix: { type: 'string', description: 'Prefix distinguishing this template' }, + }, + }, + }, + placeholders: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Placeholder-to-user mappings, each with placeholderId and user ({ userId } or { emailId }); ignored unless the project is built from sources', + items: { + type: 'object', + description: 'Placeholder mapping', + properties: { + placeholderId: { type: 'number', description: 'Unique identifier of the placeholder' }, + user: { type: 'object', description: 'User to assign ({ userId } or { emailId })' }, + }, + }, + }, + assignProjectOwner: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Automatically assign unassigned tasks to the project owner (skipped when no sources are used)', + }, + annualizedRecurringRevenue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Recurring revenue of the customer subscriptions for a single calendar year', + }, + projectFee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Total fee charged for the project', + }, + autoAllocation: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether auto allocation is enabled for the project', + }, + autoCreateCompany: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the customer company if it does not already exist', + }, + budgetedHours: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Total hours allocated for project execution (decimal, up to two places)', + }, + contractType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Contract type for the project financials: FIXED_FEE, TIME_AND_MATERIAL, NON_BILLABLE, or SUBSCRIPTION', + }, + fixedFee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Project fee for FIXED_FEE contract type projects', + }, + projectBudget: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Project budget for TIME_AND_MATERIAL contract type projects', + }, + rateCardId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Rate card ID for TIME_AND_MATERIAL contract type projects', + }, + subscriptionFrequency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Subscription renewal interval for SUBSCRIPTION contracts: MONTHLY, QUARTERLY, HALF_YEARLY, or YEARLY', + }, + subscriptionStartDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date when the subscription interval begins (YYYY-MM-DD)', + }, + periodMinutes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Budgeted minutes for each subscription period', + }, + periodBudget: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Fixed budget of every subscription period', + }, + noOfPeriods: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of periods in the subscription', + }, + currency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Currency for the project financials (ISO code, e.g. USD); cannot be changed once set', + }, + externalReferenceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identifier linking the project to an external system', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to return in the response (e.g. budgetedHours,progressPercentage)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response body', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/projects`) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + projectName: params.projectName, + customer: { companyName: params.customerCompanyName }, + } + if (params.ownerUserId == null && !params.ownerEmailId) { + throw new Error('Either an owner user ID or an owner email is required to create a project') + } + const owner: Record = {} + if (params.ownerUserId != null) owner.userId = params.ownerUserId + if (params.ownerEmailId) owner.emailId = params.ownerEmailId + body.owner = owner + if (params.startDate) body.startDate = params.startDate + if (params.dueDate) body.dueDate = params.dueDate + if (params.visibility) body.visibility = params.visibility + if (params.statusValue != null) body.status = { value: params.statusValue } + const teamMembers: Record = {} + if (params.memberUserIds && params.memberUserIds.length > 0) { + teamMembers.members = params.memberUserIds.map((userId) => ({ userId })) + } + if (params.customerUserIds && params.customerUserIds.length > 0) { + teamMembers.customers = params.customerUserIds.map((userId) => ({ userId })) + } + if (params.customerChampionUserId != null) { + teamMembers.customerChampion = { userId: params.customerChampionUserId } + } + if (Object.keys(teamMembers).length > 0) body.teamMembers = teamMembers + if (params.fields && params.fields.length > 0) body.fields = params.fields + if (params.sources && params.sources.length > 0) body.sources = params.sources + if (params.placeholders && params.placeholders.length > 0) + body.placeholders = params.placeholders + if (params.assignProjectOwner != null) body.assignProjectOwner = params.assignProjectOwner + if (params.annualizedRecurringRevenue != null) + body.annualizedRecurringRevenue = params.annualizedRecurringRevenue + if (params.projectFee != null) body.projectFee = params.projectFee + if (params.autoAllocation != null) body.autoAllocation = params.autoAllocation + if (params.autoCreateCompany != null) body.autoCreateCompany = params.autoCreateCompany + if (params.budgetedHours != null) body.budgetedHours = params.budgetedHours + if (params.contractType) { + const financials: Record = { contractType: params.contractType } + if (params.fixedFee != null) financials.fixedFeeContract = { fixedFee: params.fixedFee } + const timeAndMaterialContract: Record = {} + if (params.rateCardId != null) + timeAndMaterialContract.rateCard = { rateCardId: params.rateCardId } + if (params.projectBudget != null) + timeAndMaterialContract.projectBudget = params.projectBudget + if (Object.keys(timeAndMaterialContract).length > 0) + financials.timeAndMaterialContract = timeAndMaterialContract + const subscriptionContract: Record = {} + if (params.subscriptionFrequency) + subscriptionContract.subscriptionFrequency = params.subscriptionFrequency + if (params.subscriptionStartDate) + subscriptionContract.subscriptionStartDate = params.subscriptionStartDate + if (params.periodMinutes != null) subscriptionContract.periodMinutes = params.periodMinutes + if (params.periodBudget != null) subscriptionContract.periodBudget = params.periodBudget + if (params.noOfPeriods != null) subscriptionContract.noOfPeriods = params.noOfPeriods + if (Object.keys(subscriptionContract).length > 0) + financials.subscriptionContract = subscriptionContract + body.financials = financials + } + if (params.currency) body.currency = params.currency + if (params.externalReferenceId) body.externalReferenceId = params.externalReferenceId + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The created project', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_space.ts b/apps/sim/tools/rocketlane/create_space.ts new file mode 100644 index 00000000000..82055589493 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_space.ts @@ -0,0 +1,80 @@ +import { + mapSpace, + ROCKETLANE_API_BASE, + type RocketlaneCreateSpaceParams, + type RocketlaneSpaceResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateSpaceTool: ToolConfig< + RocketlaneCreateSpaceParams, + RocketlaneSpaceResponse +> = { + id: 'rocketlane_create_space', + name: 'Rocketlane Create Space', + description: 'Create a new space in a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project the space belongs to', + }, + spaceName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the space', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the space is private or shared (defaults to false)', + }, + }, + + request: { + url: () => `${ROCKETLANE_API_BASE}/spaces`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + spaceName: params.spaceName, + project: { projectId: params.projectId }, + } + if (params.private != null) body.private = params.private + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { space: mapSpace(data) }, + } + }, + + outputs: { + space: { + type: 'object', + description: 'The created space', + properties: SPACE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_space_document.ts b/apps/sim/tools/rocketlane/create_space_document.ts new file mode 100644 index 00000000000..54b16159acd --- /dev/null +++ b/apps/sim/tools/rocketlane/create_space_document.ts @@ -0,0 +1,94 @@ +import { + mapSpaceDocument, + ROCKETLANE_API_BASE, + type RocketlaneCreateSpaceDocumentParams, + type RocketlaneSpaceDocumentResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_DOCUMENT_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateSpaceDocumentTool: ToolConfig< + RocketlaneCreateSpaceDocumentParams, + RocketlaneSpaceDocumentResponse +> = { + id: 'rocketlane_create_space_document', + name: 'Rocketlane Create Space Document', + description: 'Create a new space document in a Rocketlane space', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space the document belongs to', + }, + spaceDocumentType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of the space document: ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT', + }, + spaceDocumentName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the space document (defaults to Untitled)', + }, + url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL to embed in the space document (for embedded documents)', + }, + templateId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the document template to create the document from', + }, + }, + + request: { + url: () => `${ROCKETLANE_API_BASE}/space-documents`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + space: { spaceId: params.spaceId }, + spaceDocumentType: params.spaceDocumentType, + } + if (params.spaceDocumentName != null) body.spaceDocumentName = params.spaceDocumentName + if (params.url != null) body.url = params.url + if (params.templateId != null) body.source = { templateId: params.templateId } + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { spaceDocument: mapSpaceDocument(data) }, + } + }, + + outputs: { + spaceDocument: { + type: 'object', + description: 'The created space document', + properties: SPACE_DOCUMENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_task.ts b/apps/sim/tools/rocketlane/create_task.ts new file mode 100644 index 00000000000..6964a6e4774 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_task.ts @@ -0,0 +1,222 @@ +import { + buildTaskMembers, + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneCreateTaskParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateTaskTool: ToolConfig< + RocketlaneCreateTaskParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_create_task', + name: 'Rocketlane Create Task', + description: 'Create a new task in a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the task', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project the task belongs to', + }, + taskDescription: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the task in HTML format', + }, + taskPrivateNote: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Private note visible only to team members, in HTML format', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date when the task starts (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date when the task is due, on or after the start date (YYYY-MM-DD)', + }, + effortInMinutes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Expected effort to complete the task, in minutes', + }, + progress: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Progress of the task (0-100)', + }, + atRisk: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the task is marked as At Risk', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Type of the task: TASK or MILESTONE (defaults to TASK)', + }, + phaseId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the phase to associate the task with (must belong to the project)', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Status value to set on the task', + }, + assigneeUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to assign to the task', + items: { type: 'number' }, + }, + assigneeEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to assign to the task', + items: { type: 'string' }, + }, + followerUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to add as followers of the task', + items: { type: 'number' }, + }, + followerEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to add as followers of the task', + items: { type: 'string' }, + }, + parentTaskId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the parent task', + }, + externalReferenceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'External reference identifier linking the task to an external system', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the task is private', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Extra fields to include in the response (startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId)', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/tasks`) + if (params.includeFields && params.includeFields.length > 0) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields !== undefined) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + taskName: params.taskName, + project: { projectId: params.projectId }, + } + if (params.taskDescription !== undefined) body.taskDescription = params.taskDescription + if (params.taskPrivateNote !== undefined) body.taskPrivateNote = params.taskPrivateNote + if (params.startDate !== undefined) body.startDate = params.startDate + if (params.dueDate !== undefined) body.dueDate = params.dueDate + if (params.effortInMinutes !== undefined) body.effortInMinutes = params.effortInMinutes + if (params.progress !== undefined) body.progress = params.progress + if (params.atRisk !== undefined) body.atRisk = params.atRisk + if (params.type !== undefined) body.type = params.type + if (params.phaseId !== undefined) body.phase = { phaseId: params.phaseId } + if (params.statusValue !== undefined) body.status = { value: params.statusValue } + const assignees = buildTaskMembers(params.assigneeUserIds, params.assigneeEmailIds) + if (assignees.length > 0) body.assignees = { members: assignees } + const followers = buildTaskMembers(params.followerUserIds, params.followerEmailIds) + if (followers.length > 0) body.followers = { members: followers } + if (params.parentTaskId !== undefined) body.parent = { taskId: params.parentTaskId } + if (params.externalReferenceId !== undefined) { + body.externalReferenceId = params.externalReferenceId + } + if (params.private !== undefined) body.private = params.private + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The created task', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_time_entry.ts b/apps/sim/tools/rocketlane/create_time_entry.ts new file mode 100644 index 00000000000..d939ce1ae72 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_time_entry.ts @@ -0,0 +1,165 @@ +import { + mapTimeEntry, + ROCKETLANE_API_BASE, + type RocketlaneCreateTimeEntryParams, + type RocketlaneTimeEntryResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateTimeEntryTool: ToolConfig< + RocketlaneCreateTimeEntryParams, + RocketlaneTimeEntryResponse +> = { + id: 'rocketlane_create_time_entry', + name: 'Rocketlane Create Time Entry', + description: + 'Create a time entry in Rocketlane against an adhoc activity, task, project phase, or project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + date: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Date of the time entry in YYYY-MM-DD format', + }, + minutes: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Duration of the time entry in minutes (1-1440)', + }, + activityName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Name of the adhoc activity to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error', + }, + taskId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the task to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error', + }, + projectPhaseId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the project phase to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error', + }, + projectId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'ID of the project to track time against. Exactly one source is required among activityName, taskId, projectPhaseId, and projectId — providing more than one results in an error', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the time entry is billable (defaults to true)', + }, + userId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the user the time entry belongs to', + }, + userEmail: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email of the user the time entry belongs to (alternative to userId)', + }, + notes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Notes for the time entry', + }, + categoryId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the time entry category', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include in the response (notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-entries`) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + date: params.date, + minutes: params.minutes, + } + if (params.activityName) body.activityName = params.activityName + if (params.taskId != null) body.task = { taskId: params.taskId } + if (params.projectPhaseId != null) body.projectPhase = { phaseId: params.projectPhaseId } + if (params.projectId != null) body.project = { projectId: params.projectId } + if (params.billable != null) body.billable = params.billable + if (params.userId != null || params.userEmail) { + const user: Record = {} + if (params.userId != null) user.userId = params.userId + if (params.userEmail) user.emailId = params.userEmail + body.user = user + } + if (params.notes) body.notes = params.notes + if (params.categoryId != null) body.category = { categoryId: params.categoryId } + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { timeEntry: mapTimeEntry(data) }, + } + }, + + outputs: { + timeEntry: { + type: 'object', + description: 'The created time entry', + properties: TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/create_time_off.ts b/apps/sim/tools/rocketlane/create_time_off.ts new file mode 100644 index 00000000000..68e1d52a0d9 --- /dev/null +++ b/apps/sim/tools/rocketlane/create_time_off.ts @@ -0,0 +1,169 @@ +import { + mapTimeOff, + ROCKETLANE_API_BASE, + type RocketlaneTimeOffCreateParams, + type RocketlaneTimeOffResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_OFF_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneCreateTimeOffTool: ToolConfig< + RocketlaneTimeOffCreateParams, + RocketlaneTimeOffResponse +> = { + id: 'rocketlane_create_time_off', + name: 'Rocketlane Create Time-Off', + description: + 'Create a time-off for a team member in Rocketlane. Holidays and weekends within the date range are automatically excluded from the duration.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + userId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'User ID of the team member taking the time-off (provide this or the user email)', + }, + userEmail: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email of the team member taking the time-off (provide this or the user ID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Time-off start date in YYYY-MM-DD format', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Time-off end date in YYYY-MM-DD format (on or after the start date)', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of the time-off: FULL_DAY, HALF_DAY, or CUSTOM', + }, + durationInMinutes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration in minutes per day; required when type is CUSTOM', + }, + note: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Note or comment about the time-off', + }, + notifyProjectOwners: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Notify project owners of projects the user is part of', + }, + notifyUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of additional users to notify about the time-off', + items: { type: 'number' }, + }, + notifyUserEmails: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Emails of additional users to notify about the time-off', + items: { type: 'string' }, + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Optional fields to include in the response: note, notifyUsers', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-offs`) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + if (params.userId == null && !params.userEmail) { + throw new Error('Either a user ID or a user email is required to create a time-off') + } + const user: Record = {} + if (params.userId != null) user.userId = params.userId + if (params.userEmail) user.emailId = params.userEmail + const body: Record = { + user, + startDate: params.startDate, + endDate: params.endDate, + type: params.type, + } + if (params.note) body.note = params.note + if (params.durationInMinutes != null) body.durationInMinutes = params.durationInMinutes + const others = [ + ...(params.notifyUserIds ?? []).map((userId) => ({ userId })), + ...(params.notifyUserEmails ?? []).map((emailId) => ({ emailId })), + ] + if (params.notifyProjectOwners != null || others.length > 0) { + const notifyUsers: Record = {} + if (params.notifyProjectOwners != null) { + notifyUsers.projectOwners = params.notifyProjectOwners + } + if (others.length > 0) notifyUsers.others = others + body.notifyUsers = notifyUsers + } + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { timeOff: mapTimeOff(data) }, + } + }, + + outputs: { + timeOff: { + type: 'object', + description: 'The created time-off', + properties: TIME_OFF_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_field.ts b/apps/sim/tools/rocketlane/delete_field.ts new file mode 100644 index 00000000000..78b35cb8cdd --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_field.ts @@ -0,0 +1,54 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteFieldParams, + type RocketlaneFieldDeleteResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteFieldTool: ToolConfig< + RocketlaneDeleteFieldParams, + RocketlaneFieldDeleteResponse +> = { + id: 'rocketlane_delete_field', + name: 'Rocketlane Delete Field', + description: 'Permanently delete a Rocketlane field by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the field to delete', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/fields/${encodeURIComponent(String(params.fieldId))}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteFieldParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, fieldId: params?.fieldId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the field was deleted' }, + fieldId: { type: 'number', description: 'ID of the deleted field', optional: true }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_phase.ts b/apps/sim/tools/rocketlane/delete_phase.ts new file mode 100644 index 00000000000..88296907c8c --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_phase.ts @@ -0,0 +1,54 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeletePhaseParams, + type RocketlanePhaseDeleteResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeletePhaseTool: ToolConfig< + RocketlaneDeletePhaseParams, + RocketlanePhaseDeleteResponse +> = { + id: 'rocketlane_delete_phase', + name: 'Rocketlane Delete Phase', + description: 'Permanently delete a Rocketlane phase by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + phaseId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the phase to delete', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/phases/${encodeURIComponent(String(params.phaseId))}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeletePhaseParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, phaseId: params?.phaseId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the phase was deleted' }, + phaseId: { type: 'number', description: 'ID of the deleted phase', optional: true }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_project.ts b/apps/sim/tools/rocketlane/delete_project.ts new file mode 100644 index 00000000000..c4e834b71a9 --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_project.ts @@ -0,0 +1,60 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteProjectParams, + type RocketlaneProjectDeleteResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteProjectTool: ToolConfig< + RocketlaneDeleteProjectParams, + RocketlaneProjectDeleteResponse +> = { + id: 'rocketlane_delete_project', + name: 'Rocketlane Delete Project', + description: + 'Permanently delete a Rocketlane project by ID (irreversible; only Admins, Super Users, and Project Owners can delete)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project to delete', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteProjectParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, projectId: params?.projectId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the project was deleted' }, + projectId: { + type: 'number', + description: 'Unique identifier of the deleted project', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_space.ts b/apps/sim/tools/rocketlane/delete_space.ts new file mode 100644 index 00000000000..38569b073af --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_space.ts @@ -0,0 +1,54 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteSpaceParams, + type RocketlaneDeleteSpaceResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteSpaceTool: ToolConfig< + RocketlaneDeleteSpaceParams, + RocketlaneDeleteSpaceResponse +> = { + id: 'rocketlane_delete_space', + name: 'Rocketlane Delete Space', + description: 'Permanently delete a Rocketlane space by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to delete', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/spaces/${encodeURIComponent(params.spaceId)}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteSpaceParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, spaceId: params?.spaceId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the space was deleted' }, + spaceId: { type: 'number', description: 'ID of the deleted space', optional: true }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_space_document.ts b/apps/sim/tools/rocketlane/delete_space_document.ts new file mode 100644 index 00000000000..4303e612058 --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_space_document.ts @@ -0,0 +1,59 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteSpaceDocumentParams, + type RocketlaneDeleteSpaceDocumentResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteSpaceDocumentTool: ToolConfig< + RocketlaneDeleteSpaceDocumentParams, + RocketlaneDeleteSpaceDocumentResponse +> = { + id: 'rocketlane_delete_space_document', + name: 'Rocketlane Delete Space Document', + description: 'Permanently delete a Rocketlane space document by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceDocumentId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space document to delete', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/space-documents/${encodeURIComponent(params.spaceDocumentId)}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteSpaceDocumentParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, spaceDocumentId: params?.spaceDocumentId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the space document was deleted' }, + spaceDocumentId: { + type: 'number', + description: 'ID of the deleted space document', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_task.ts b/apps/sim/tools/rocketlane/delete_task.ts new file mode 100644 index 00000000000..7e2a65dc2bd --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_task.ts @@ -0,0 +1,54 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteTaskParams, + type RocketlaneTaskDeleteResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteTaskTool: ToolConfig< + RocketlaneDeleteTaskParams, + RocketlaneTaskDeleteResponse +> = { + id: 'rocketlane_delete_task', + name: 'Rocketlane Delete Task', + description: 'Permanently delete a Rocketlane task by its unique identifier', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to delete', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteTaskParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, taskId: params?.taskId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the task was deleted' }, + taskId: { type: 'number', description: 'ID of the deleted task', optional: true }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_time_entry.ts b/apps/sim/tools/rocketlane/delete_time_entry.ts new file mode 100644 index 00000000000..46b140387b2 --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_time_entry.ts @@ -0,0 +1,59 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneDeleteTimeEntryParams, + type RocketlaneDeleteTimeEntryResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteTimeEntryTool: ToolConfig< + RocketlaneDeleteTimeEntryParams, + RocketlaneDeleteTimeEntryResponse +> = { + id: 'rocketlane_delete_time_entry', + name: 'Rocketlane Delete Time Entry', + description: 'Permanently delete a Rocketlane time entry by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + timeEntryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to delete', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/time-entries/${encodeURIComponent(params.timeEntryId)}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneDeleteTimeEntryParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, timeEntryId: params?.timeEntryId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the time entry was deleted' }, + timeEntryId: { + type: 'number', + description: 'ID of the deleted time entry', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/delete_time_off.ts b/apps/sim/tools/rocketlane/delete_time_off.ts new file mode 100644 index 00000000000..18a5c3f819b --- /dev/null +++ b/apps/sim/tools/rocketlane/delete_time_off.ts @@ -0,0 +1,58 @@ +import { + ROCKETLANE_API_BASE, + type RocketlaneTimeOffDeleteParams, + type RocketlaneTimeOffDeleteResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneDeleteTimeOffTool: ToolConfig< + RocketlaneTimeOffDeleteParams, + RocketlaneTimeOffDeleteResponse +> = { + id: 'rocketlane_delete_time_off', + name: 'Rocketlane Delete Time-Off', + description: 'Permanently delete a Rocketlane time-off by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + timeOffId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the time-off to delete', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/time-offs/${encodeURIComponent(params.timeOffId)}`, + method: 'DELETE', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response, params?: RocketlaneTimeOffDeleteParams) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + return { + success: true, + output: { deleted: true, timeOffId: params?.timeOffId ?? null }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the time-off was deleted' }, + timeOffId: { + type: 'number', + description: 'ID of the deleted time-off', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_field.ts b/apps/sim/tools/rocketlane/get_field.ts new file mode 100644 index 00000000000..eb8f31e44a5 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_field.ts @@ -0,0 +1,80 @@ +import { + FIELD_OUTPUT_PROPERTIES, + mapField, + ROCKETLANE_API_BASE, + type RocketlaneFieldResponse, + type RocketlaneGetFieldParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetFieldTool: ToolConfig = + { + id: 'rocketlane_get_field', + name: 'Rocketlane Get Field', + description: 'Retrieve a single Rocketlane field by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the field to retrieve', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra field properties to include in the response (supported: options)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all field properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/fields/${encodeURIComponent(String(params.fieldId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { field: mapField(data) }, + } + }, + + outputs: { + field: { + type: 'object', + description: 'The requested field', + properties: FIELD_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/rocketlane/get_invoice.ts b/apps/sim/tools/rocketlane/get_invoice.ts new file mode 100644 index 00000000000..1ca218b8511 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_invoice.ts @@ -0,0 +1,82 @@ +import { + INVOICE_OUTPUT_PROPERTIES, + mapInvoice, + ROCKETLANE_API_BASE, + type RocketlaneInvoiceGetParams, + type RocketlaneInvoiceResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetInvoiceTool: ToolConfig< + RocketlaneInvoiceGetParams, + RocketlaneInvoiceResponse +> = { + id: 'rocketlane_get_invoice', + name: 'Rocketlane Get Invoice', + description: 'Retrieve a Rocketlane invoice by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + invoiceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the invoice', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Optional fields to include in the response: notes, attachments', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/invoices/${encodeURIComponent(params.invoiceId)}`) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { invoice: mapInvoice(data) }, + } + }, + + outputs: { + invoice: { + type: 'object', + description: 'The requested invoice', + properties: INVOICE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_invoice_line_items.ts b/apps/sim/tools/rocketlane/get_invoice_line_items.ts new file mode 100644 index 00000000000..5ff7c7daaee --- /dev/null +++ b/apps/sim/tools/rocketlane/get_invoice_line_items.ts @@ -0,0 +1,90 @@ +import { + INVOICE_LINE_ITEM_OUTPUT_PROPERTIES, + mapInvoiceLineItem, + mapPagination, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneInvoiceLineItemListResponse, + type RocketlaneInvoiceLineItemsParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetInvoiceLineItemsTool: ToolConfig< + RocketlaneInvoiceLineItemsParams, + RocketlaneInvoiceLineItemListResponse +> = { + id: 'rocketlane_get_invoice_line_items', + name: 'Rocketlane Get Invoice Line Items', + description: 'List line items of a Rocketlane invoice', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + invoiceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the invoice', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of line items per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/invoices/${encodeURIComponent(params.invoiceId)}/lines` + ) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const lineItems = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + lineItems: lineItems.map(mapInvoiceLineItem), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + lineItems: { + type: 'array', + description: 'List of invoice line items', + items: { type: 'object', properties: INVOICE_LINE_ITEM_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_invoice_payments.ts b/apps/sim/tools/rocketlane/get_invoice_payments.ts new file mode 100644 index 00000000000..d35564dd642 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_invoice_payments.ts @@ -0,0 +1,90 @@ +import { + INVOICE_PAYMENT_OUTPUT_PROPERTIES, + mapInvoicePayment, + mapPagination, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneInvoicePaymentListResponse, + type RocketlaneInvoicePaymentsParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetInvoicePaymentsTool: ToolConfig< + RocketlaneInvoicePaymentsParams, + RocketlaneInvoicePaymentListResponse +> = { + id: 'rocketlane_get_invoice_payments', + name: 'Rocketlane Get Invoice Payments', + description: 'List payments recorded against a Rocketlane invoice', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + invoiceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the invoice', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of payments per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/invoices/${encodeURIComponent(params.invoiceId)}/payments` + ) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const payments = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + payments: payments.map(mapInvoicePayment), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + payments: { + type: 'array', + description: 'List of payments recorded against the invoice', + items: { type: 'object', properties: INVOICE_PAYMENT_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_phase.ts b/apps/sim/tools/rocketlane/get_phase.ts new file mode 100644 index 00000000000..73996a265f9 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_phase.ts @@ -0,0 +1,80 @@ +import { + mapPhase, + PHASE_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneGetPhaseParams, + type RocketlanePhaseResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetPhaseTool: ToolConfig = + { + id: 'rocketlane_get_phase', + name: 'Rocketlane Get Phase', + description: 'Retrieve a single Rocketlane phase by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + phaseId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the phase to retrieve', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra phase properties to include in the response (supported: startDateActual, dueDateActual)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all phase properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/phases/${encodeURIComponent(String(params.phaseId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { phase: mapPhase(data) }, + } + }, + + outputs: { + phase: { + type: 'object', + description: 'The requested phase', + properties: PHASE_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/rocketlane/get_project.ts b/apps/sim/tools/rocketlane/get_project.ts new file mode 100644 index 00000000000..d29e42e83b6 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_project.ts @@ -0,0 +1,81 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneGetProjectParams, + type RocketlaneProjectResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetProjectTool: ToolConfig< + RocketlaneGetProjectParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_get_project', + name: 'Rocketlane Get Project', + description: 'Retrieve a Rocketlane project by its unique identifier', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to return in the response (e.g. budgetedHours,progressPercentage)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response body', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The requested project', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_space.ts b/apps/sim/tools/rocketlane/get_space.ts new file mode 100644 index 00000000000..376254f5088 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_space.ts @@ -0,0 +1,58 @@ +import { + mapSpace, + ROCKETLANE_API_BASE, + type RocketlaneGetSpaceParams, + type RocketlaneSpaceResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetSpaceTool: ToolConfig = + { + id: 'rocketlane_get_space', + name: 'Rocketlane Get Space', + description: 'Retrieve a Rocketlane space by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to retrieve', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/spaces/${encodeURIComponent(params.spaceId)}`, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { space: mapSpace(data) }, + } + }, + + outputs: { + space: { + type: 'object', + description: 'The requested space', + properties: SPACE_OUTPUT_PROPERTIES, + }, + }, + } diff --git a/apps/sim/tools/rocketlane/get_space_document.ts b/apps/sim/tools/rocketlane/get_space_document.ts new file mode 100644 index 00000000000..a1a002fa3e8 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_space_document.ts @@ -0,0 +1,61 @@ +import { + mapSpaceDocument, + ROCKETLANE_API_BASE, + type RocketlaneGetSpaceDocumentParams, + type RocketlaneSpaceDocumentResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_DOCUMENT_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetSpaceDocumentTool: ToolConfig< + RocketlaneGetSpaceDocumentParams, + RocketlaneSpaceDocumentResponse +> = { + id: 'rocketlane_get_space_document', + name: 'Rocketlane Get Space Document', + description: 'Retrieve a Rocketlane space document by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceDocumentId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space document to retrieve', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/space-documents/${encodeURIComponent(params.spaceDocumentId)}`, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { spaceDocument: mapSpaceDocument(data) }, + } + }, + + outputs: { + spaceDocument: { + type: 'object', + description: 'The requested space document', + properties: SPACE_DOCUMENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_task.ts b/apps/sim/tools/rocketlane/get_task.ts new file mode 100644 index 00000000000..40e6470a348 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_task.ts @@ -0,0 +1,82 @@ +import { + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneGetTaskParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetTaskTool: ToolConfig = { + id: 'rocketlane_get_task', + name: 'Rocketlane Get Task', + description: 'Retrieve detailed information about a Rocketlane task by its unique identifier', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to retrieve', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Extra fields to include in the response (startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId)', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}` + ) + if (params.includeFields && params.includeFields.length > 0) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields !== undefined) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The requested task', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_time_entry.ts b/apps/sim/tools/rocketlane/get_time_entry.ts new file mode 100644 index 00000000000..fe899339117 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_time_entry.ts @@ -0,0 +1,82 @@ +import { + mapTimeEntry, + ROCKETLANE_API_BASE, + type RocketlaneGetTimeEntryParams, + type RocketlaneTimeEntryResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetTimeEntryTool: ToolConfig< + RocketlaneGetTimeEntryParams, + RocketlaneTimeEntryResponse +> = { + id: 'rocketlane_get_time_entry', + name: 'Rocketlane Get Time Entry', + description: 'Get a single Rocketlane time entry by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + timeEntryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to retrieve', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include in the response (notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/time-entries/${encodeURIComponent(params.timeEntryId)}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { timeEntry: mapTimeEntry(data) }, + } + }, + + outputs: { + timeEntry: { + type: 'object', + description: 'The requested time entry', + properties: TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_time_off.ts b/apps/sim/tools/rocketlane/get_time_off.ts new file mode 100644 index 00000000000..d858ffff874 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_time_off.ts @@ -0,0 +1,84 @@ +import { + mapTimeOff, + ROCKETLANE_API_BASE, + type RocketlaneTimeOffGetParams, + type RocketlaneTimeOffResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_OFF_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetTimeOffTool: ToolConfig< + RocketlaneTimeOffGetParams, + RocketlaneTimeOffResponse +> = { + id: 'rocketlane_get_time_off', + name: 'Rocketlane Get Time-Off', + description: 'Retrieve a Rocketlane time-off by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + timeOffId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the time-off', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Optional fields to include in the response: note, notifyUsers', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/time-offs/${encodeURIComponent(params.timeOffId)}` + ) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { timeOff: mapTimeOff(data) }, + } + }, + + outputs: { + timeOff: { + type: 'object', + description: 'The requested time-off', + properties: TIME_OFF_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/get_user.ts b/apps/sim/tools/rocketlane/get_user.ts new file mode 100644 index 00000000000..262170f9830 --- /dev/null +++ b/apps/sim/tools/rocketlane/get_user.ts @@ -0,0 +1,76 @@ +import { + mapUser, + ROCKETLANE_API_BASE, + type RocketlaneGetUserParams, + type RocketlaneUserResponse, + rocketlaneError, + rocketlaneHeaders, + USER_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneGetUserTool: ToolConfig = { + id: 'rocketlane_get_user', + name: 'Rocketlane Get User', + description: 'Retrieve a Rocketlane user by their ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the user to retrieve', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include: role, company, permission, holidayCalendar, capacityInMinutes, profilePictureUrl', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include all fields in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/users/${encodeURIComponent(params.userId)}`) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { user: mapUser(data) }, + } + }, + + outputs: { + user: { + type: 'object', + description: 'The requested user', + properties: USER_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/import_template.ts b/apps/sim/tools/rocketlane/import_template.ts new file mode 100644 index 00000000000..b66b232c59a --- /dev/null +++ b/apps/sim/tools/rocketlane/import_template.ts @@ -0,0 +1,88 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneImportTemplateParams, + type RocketlaneProjectResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneImportTemplateTool: ToolConfig< + RocketlaneImportTemplateParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_import_template', + name: 'Rocketlane Import Template', + description: 'Import a project template into an existing Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project to import the template into', + }, + templateId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the template to import', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Date on which the template goes into effect for the project (YYYY-MM-DD)', + }, + prefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Prefix distinguishing which phase or task corresponds to this template when importing multiple templates', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/import-template`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const source: Record = { + templateId: params.templateId, + startDate: params.startDate, + } + if (params.prefix) source.prefix = params.prefix + return [source] + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The project after the template import (including its sources)', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/index.ts b/apps/sim/tools/rocketlane/index.ts new file mode 100644 index 00000000000..1cd69057f78 --- /dev/null +++ b/apps/sim/tools/rocketlane/index.ts @@ -0,0 +1,131 @@ +import { rocketlaneAddFieldOptionTool } from '@/tools/rocketlane/add_field_option' +import { rocketlaneAddProjectMembersTool } from '@/tools/rocketlane/add_project_members' +import { rocketlaneAddTaskAssigneesTool } from '@/tools/rocketlane/add_task_assignees' +import { rocketlaneAddTaskDependenciesTool } from '@/tools/rocketlane/add_task_dependencies' +import { rocketlaneAddTaskFollowersTool } from '@/tools/rocketlane/add_task_followers' +import { rocketlaneArchiveProjectTool } from '@/tools/rocketlane/archive_project' +import { rocketlaneAssignPlaceholdersTool } from '@/tools/rocketlane/assign_placeholders' +import { rocketlaneCreateFieldTool } from '@/tools/rocketlane/create_field' +import { rocketlaneCreatePhaseTool } from '@/tools/rocketlane/create_phase' +import { rocketlaneCreateProjectTool } from '@/tools/rocketlane/create_project' +import { rocketlaneCreateSpaceTool } from '@/tools/rocketlane/create_space' +import { rocketlaneCreateSpaceDocumentTool } from '@/tools/rocketlane/create_space_document' +import { rocketlaneCreateTaskTool } from '@/tools/rocketlane/create_task' +import { rocketlaneCreateTimeEntryTool } from '@/tools/rocketlane/create_time_entry' +import { rocketlaneCreateTimeOffTool } from '@/tools/rocketlane/create_time_off' +import { rocketlaneDeleteFieldTool } from '@/tools/rocketlane/delete_field' +import { rocketlaneDeletePhaseTool } from '@/tools/rocketlane/delete_phase' +import { rocketlaneDeleteProjectTool } from '@/tools/rocketlane/delete_project' +import { rocketlaneDeleteSpaceTool } from '@/tools/rocketlane/delete_space' +import { rocketlaneDeleteSpaceDocumentTool } from '@/tools/rocketlane/delete_space_document' +import { rocketlaneDeleteTaskTool } from '@/tools/rocketlane/delete_task' +import { rocketlaneDeleteTimeEntryTool } from '@/tools/rocketlane/delete_time_entry' +import { rocketlaneDeleteTimeOffTool } from '@/tools/rocketlane/delete_time_off' +import { rocketlaneGetFieldTool } from '@/tools/rocketlane/get_field' +import { rocketlaneGetInvoiceTool } from '@/tools/rocketlane/get_invoice' +import { rocketlaneGetInvoiceLineItemsTool } from '@/tools/rocketlane/get_invoice_line_items' +import { rocketlaneGetInvoicePaymentsTool } from '@/tools/rocketlane/get_invoice_payments' +import { rocketlaneGetPhaseTool } from '@/tools/rocketlane/get_phase' +import { rocketlaneGetProjectTool } from '@/tools/rocketlane/get_project' +import { rocketlaneGetSpaceTool } from '@/tools/rocketlane/get_space' +import { rocketlaneGetSpaceDocumentTool } from '@/tools/rocketlane/get_space_document' +import { rocketlaneGetTaskTool } from '@/tools/rocketlane/get_task' +import { rocketlaneGetTimeEntryTool } from '@/tools/rocketlane/get_time_entry' +import { rocketlaneGetTimeOffTool } from '@/tools/rocketlane/get_time_off' +import { rocketlaneGetUserTool } from '@/tools/rocketlane/get_user' +import { rocketlaneImportTemplateTool } from '@/tools/rocketlane/import_template' +import { rocketlaneListFieldsTool } from '@/tools/rocketlane/list_fields' +import { rocketlaneListInvoicesTool } from '@/tools/rocketlane/list_invoices' +import { rocketlaneListPhasesTool } from '@/tools/rocketlane/list_phases' +import { rocketlaneListPlaceholdersTool } from '@/tools/rocketlane/list_placeholders' +import { rocketlaneListProjectsTool } from '@/tools/rocketlane/list_projects' +import { rocketlaneListResourceAllocationsTool } from '@/tools/rocketlane/list_resource_allocations' +import { rocketlaneListSpaceDocumentsTool } from '@/tools/rocketlane/list_space_documents' +import { rocketlaneListSpacesTool } from '@/tools/rocketlane/list_spaces' +import { rocketlaneListTasksTool } from '@/tools/rocketlane/list_tasks' +import { rocketlaneListTimeEntriesTool } from '@/tools/rocketlane/list_time_entries' +import { rocketlaneListTimeEntryCategoriesTool } from '@/tools/rocketlane/list_time_entry_categories' +import { rocketlaneListTimeOffsTool } from '@/tools/rocketlane/list_time_offs' +import { rocketlaneListUsersTool } from '@/tools/rocketlane/list_users' +import { rocketlaneMoveTaskToPhaseTool } from '@/tools/rocketlane/move_task_to_phase' +import { rocketlaneRemoveProjectMembersTool } from '@/tools/rocketlane/remove_project_members' +import { rocketlaneRemoveTaskAssigneesTool } from '@/tools/rocketlane/remove_task_assignees' +import { rocketlaneRemoveTaskDependenciesTool } from '@/tools/rocketlane/remove_task_dependencies' +import { rocketlaneRemoveTaskFollowersTool } from '@/tools/rocketlane/remove_task_followers' +import { rocketlaneSearchTimeEntriesTool } from '@/tools/rocketlane/search_time_entries' +import { rocketlaneUnassignPlaceholdersTool } from '@/tools/rocketlane/unassign_placeholders' +import { rocketlaneUpdateFieldTool } from '@/tools/rocketlane/update_field' +import { rocketlaneUpdateFieldOptionTool } from '@/tools/rocketlane/update_field_option' +import { rocketlaneUpdatePhaseTool } from '@/tools/rocketlane/update_phase' +import { rocketlaneUpdateProjectTool } from '@/tools/rocketlane/update_project' +import { rocketlaneUpdateSpaceTool } from '@/tools/rocketlane/update_space' +import { rocketlaneUpdateSpaceDocumentTool } from '@/tools/rocketlane/update_space_document' +import { rocketlaneUpdateTaskTool } from '@/tools/rocketlane/update_task' +import { rocketlaneUpdateTimeEntryTool } from '@/tools/rocketlane/update_time_entry' + +export { + rocketlaneAddFieldOptionTool, + rocketlaneAddProjectMembersTool, + rocketlaneAddTaskAssigneesTool, + rocketlaneAddTaskDependenciesTool, + rocketlaneAddTaskFollowersTool, + rocketlaneArchiveProjectTool, + rocketlaneAssignPlaceholdersTool, + rocketlaneCreateFieldTool, + rocketlaneCreatePhaseTool, + rocketlaneCreateProjectTool, + rocketlaneCreateSpaceDocumentTool, + rocketlaneCreateSpaceTool, + rocketlaneCreateTaskTool, + rocketlaneCreateTimeEntryTool, + rocketlaneCreateTimeOffTool, + rocketlaneDeleteFieldTool, + rocketlaneDeletePhaseTool, + rocketlaneDeleteProjectTool, + rocketlaneDeleteSpaceDocumentTool, + rocketlaneDeleteSpaceTool, + rocketlaneDeleteTaskTool, + rocketlaneDeleteTimeEntryTool, + rocketlaneDeleteTimeOffTool, + rocketlaneGetFieldTool, + rocketlaneGetInvoiceLineItemsTool, + rocketlaneGetInvoicePaymentsTool, + rocketlaneGetInvoiceTool, + rocketlaneGetPhaseTool, + rocketlaneGetProjectTool, + rocketlaneGetSpaceDocumentTool, + rocketlaneGetSpaceTool, + rocketlaneGetTaskTool, + rocketlaneGetTimeEntryTool, + rocketlaneGetTimeOffTool, + rocketlaneGetUserTool, + rocketlaneImportTemplateTool, + rocketlaneListFieldsTool, + rocketlaneListInvoicesTool, + rocketlaneListPhasesTool, + rocketlaneListPlaceholdersTool, + rocketlaneListProjectsTool, + rocketlaneListResourceAllocationsTool, + rocketlaneListSpaceDocumentsTool, + rocketlaneListSpacesTool, + rocketlaneListTasksTool, + rocketlaneListTimeEntriesTool, + rocketlaneListTimeEntryCategoriesTool, + rocketlaneListTimeOffsTool, + rocketlaneListUsersTool, + rocketlaneMoveTaskToPhaseTool, + rocketlaneRemoveProjectMembersTool, + rocketlaneRemoveTaskAssigneesTool, + rocketlaneRemoveTaskDependenciesTool, + rocketlaneRemoveTaskFollowersTool, + rocketlaneSearchTimeEntriesTool, + rocketlaneUnassignPlaceholdersTool, + rocketlaneUpdateFieldOptionTool, + rocketlaneUpdateFieldTool, + rocketlaneUpdatePhaseTool, + rocketlaneUpdateProjectTool, + rocketlaneUpdateSpaceDocumentTool, + rocketlaneUpdateSpaceTool, + rocketlaneUpdateTaskTool, + rocketlaneUpdateTimeEntryTool, +} diff --git a/apps/sim/tools/rocketlane/list_fields.ts b/apps/sim/tools/rocketlane/list_fields.ts new file mode 100644 index 00000000000..b5a8100ff26 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_fields.ts @@ -0,0 +1,150 @@ +import { + FIELD_OUTPUT_PROPERTIES, + mapField, + mapPagination, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneFieldListResponse, + type RocketlaneListFieldsParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListFieldsTool: ToolConfig< + RocketlaneListFieldsParams, + RocketlaneFieldListResponse +> = { + id: 'rocketlane_list_fields', + name: 'Rocketlane List Fields', + description: + 'List fields in your Rocketlane account, with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of fields per page', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token returned by a previous request (valid for 15 minutes)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra field properties to include in the response (supported: options)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all field properties in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Property to sort by (supported: fieldLabel)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order (ASC or DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Whether results must match all filters or any filter (all or any)', + }, + objectType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by associated object type (PROJECT, TASK, or USER)', + }, + fieldType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by field type (TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING)', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Filter by enabled state', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Filter by privacy setting', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/fields`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.objectType) url.searchParams.set('objectType.eq', params.objectType) + if (params.fieldType) url.searchParams.set('fieldType.eq', params.fieldType) + if (params.enabled != null) url.searchParams.set('enabled.eq', String(params.enabled)) + if (params.private != null) url.searchParams.set('private.eq', String(params.private)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const fields = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + fields: fields.map(mapField), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + fields: { + type: 'array', + description: 'List of fields', + items: { type: 'object', properties: FIELD_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_invoices.ts b/apps/sim/tools/rocketlane/list_invoices.ts new file mode 100644 index 00000000000..915d45dd5c7 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_invoices.ts @@ -0,0 +1,481 @@ +import { + INVOICE_OUTPUT_PROPERTIES, + mapInvoice, + mapPagination, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneInvoiceListParams, + type RocketlaneInvoiceListResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListInvoicesTool: ToolConfig< + RocketlaneInvoiceListParams, + RocketlaneInvoiceListResponse +> = { + id: 'rocketlane_list_invoices', + name: 'Rocketlane List Invoices', + description: + 'Search invoices in Rocketlane with date, amount, company, invoice-number, and status filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of invoices per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Optional fields to include in the response: notes, attachments', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by: createdAt or invoiceNumber', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Combine filters with AND (all) or OR (any); defaults to all', + }, + dateOfIssueEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by date of issue equal to this date (YYYY-MM-DD)', + }, + dateOfIssueGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by date of issue greater than this date (YYYY-MM-DD)', + }, + dateOfIssueGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by date of issue greater than or equal to this date (YYYY-MM-DD)', + }, + dateOfIssueLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by date of issue less than this date (YYYY-MM-DD)', + }, + dateOfIssueLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by date of issue less than or equal to this date (YYYY-MM-DD)', + }, + dueDateEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by due date equal to this date (YYYY-MM-DD)', + }, + dueDateGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by due date greater than this date (YYYY-MM-DD)', + }, + dueDateGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by due date greater than or equal to this date (YYYY-MM-DD)', + }, + dueDateLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by due date less than this date (YYYY-MM-DD)', + }, + dueDateLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by due date less than or equal to this date (YYYY-MM-DD)', + }, + amountEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by total amount equal to this value', + }, + amountGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by total amount greater than this value', + }, + amountGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by total amount greater than or equal to this value', + }, + amountLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by total amount less than this value', + }, + amountLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by total amount less than or equal to this value', + }, + amountOutstandingEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount outstanding equal to this value', + }, + amountOutstandingGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount outstanding greater than this value', + }, + amountOutstandingGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount outstanding greater than or equal to this value', + }, + amountOutstandingLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount outstanding less than this value', + }, + amountOutstandingLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount outstanding less than or equal to this value', + }, + amountPaidEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount paid equal to this value', + }, + amountPaidGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount paid greater than this value', + }, + amountPaidGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount paid greater than or equal to this value', + }, + amountPaidLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount paid less than this value', + }, + amountPaidLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount paid less than or equal to this value', + }, + amountWrittenOffEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount written off equal to this value', + }, + amountWrittenOffGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount written off greater than this value', + }, + amountWrittenOffGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount written off greater than or equal to this value', + }, + amountWrittenOffLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount written off less than this value', + }, + amountWrittenOffLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by amount written off less than or equal to this value', + }, + createdAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by created timestamp equal to this value (epoch milliseconds)', + }, + createdAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by created timestamp greater than this value (epoch milliseconds)', + }, + createdAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by created timestamp greater than or equal to this value (epoch milliseconds)', + }, + createdAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter by created timestamp less than this value (epoch milliseconds)', + }, + createdAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by created timestamp less than or equal to this value (epoch milliseconds)', + }, + companyIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return invoices that exactly match this customer company ID', + }, + companyIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated customer company IDs to match any of', + }, + companyIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated customer company IDs to match none of', + }, + invoiceNumberEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return invoices whose invoice number equals this value', + }, + invoiceNumberCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return invoices whose invoice number contains this text', + }, + invoiceNumberNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return invoices whose invoice number does not contain this text', + }, + statusEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return invoices that equal this status (e.g. DRAFT)', + }, + statusOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated statuses to match any of (e.g. DRAFT,PAID)', + }, + statusNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated statuses to match none of (e.g. DRAFT,PAID)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/invoices`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.dateOfIssueEq) url.searchParams.set('dateOfIssue.eq', params.dateOfIssueEq) + if (params.dateOfIssueGt) url.searchParams.set('dateOfIssue.gt', params.dateOfIssueGt) + if (params.dateOfIssueGe) url.searchParams.set('dateOfIssue.ge', params.dateOfIssueGe) + if (params.dateOfIssueLt) url.searchParams.set('dateOfIssue.lt', params.dateOfIssueLt) + if (params.dateOfIssueLe) url.searchParams.set('dateOfIssue.le', params.dateOfIssueLe) + if (params.dueDateEq) url.searchParams.set('dueDate.eq', params.dueDateEq) + if (params.dueDateGt) url.searchParams.set('dueDate.gt', params.dueDateGt) + if (params.dueDateGe) url.searchParams.set('dueDate.ge', params.dueDateGe) + if (params.dueDateLt) url.searchParams.set('dueDate.lt', params.dueDateLt) + if (params.dueDateLe) url.searchParams.set('dueDate.le', params.dueDateLe) + if (params.amountEq != null) url.searchParams.set('amount.eq', String(params.amountEq)) + if (params.amountGt != null) url.searchParams.set('amount.gt', String(params.amountGt)) + if (params.amountGe != null) url.searchParams.set('amount.ge', String(params.amountGe)) + if (params.amountLt != null) url.searchParams.set('amount.lt', String(params.amountLt)) + if (params.amountLe != null) url.searchParams.set('amount.le', String(params.amountLe)) + if (params.amountOutstandingEq != null) { + url.searchParams.set('amountOutstanding.eq', String(params.amountOutstandingEq)) + } + if (params.amountOutstandingGt != null) { + url.searchParams.set('amountOutstanding.gt', String(params.amountOutstandingGt)) + } + if (params.amountOutstandingGe != null) { + url.searchParams.set('amountOutstanding.ge', String(params.amountOutstandingGe)) + } + if (params.amountOutstandingLt != null) { + url.searchParams.set('amountOutstanding.lt', String(params.amountOutstandingLt)) + } + if (params.amountOutstandingLe != null) { + url.searchParams.set('amountOutstanding.le', String(params.amountOutstandingLe)) + } + if (params.amountPaidEq != null) { + url.searchParams.set('amountPaid.eq', String(params.amountPaidEq)) + } + if (params.amountPaidGt != null) { + url.searchParams.set('amountPaid.gt', String(params.amountPaidGt)) + } + if (params.amountPaidGe != null) { + url.searchParams.set('amountPaid.ge', String(params.amountPaidGe)) + } + if (params.amountPaidLt != null) { + url.searchParams.set('amountPaid.lt', String(params.amountPaidLt)) + } + if (params.amountPaidLe != null) { + url.searchParams.set('amountPaid.le', String(params.amountPaidLe)) + } + if (params.amountWrittenOffEq != null) { + url.searchParams.set('amountWrittenOff.eq', String(params.amountWrittenOffEq)) + } + if (params.amountWrittenOffGt != null) { + url.searchParams.set('amountWrittenOff.gt', String(params.amountWrittenOffGt)) + } + if (params.amountWrittenOffGe != null) { + url.searchParams.set('amountWrittenOff.ge', String(params.amountWrittenOffGe)) + } + if (params.amountWrittenOffLt != null) { + url.searchParams.set('amountWrittenOff.lt', String(params.amountWrittenOffLt)) + } + if (params.amountWrittenOffLe != null) { + url.searchParams.set('amountWrittenOff.le', String(params.amountWrittenOffLe)) + } + if (params.createdAtEq != null) { + url.searchParams.set('createdAt.eq', String(params.createdAtEq)) + } + if (params.createdAtGt != null) { + url.searchParams.set('createdAt.gt', String(params.createdAtGt)) + } + if (params.createdAtGe != null) { + url.searchParams.set('createdAt.ge', String(params.createdAtGe)) + } + if (params.createdAtLt != null) { + url.searchParams.set('createdAt.lt', String(params.createdAtLt)) + } + if (params.createdAtLe != null) { + url.searchParams.set('createdAt.le', String(params.createdAtLe)) + } + if (params.companyIdEq) url.searchParams.set('companyId.eq', params.companyIdEq) + if (params.companyIdOneOf) url.searchParams.set('companyId.oneOf', params.companyIdOneOf) + if (params.companyIdNoneOf) { + url.searchParams.set('companyId.noneOf', params.companyIdNoneOf) + } + if (params.invoiceNumberEq) { + url.searchParams.set('invoiceNumber.eq', params.invoiceNumberEq) + } + if (params.invoiceNumberCn) { + url.searchParams.set('invoiceNumber.cn', params.invoiceNumberCn) + } + if (params.invoiceNumberNc) { + url.searchParams.set('invoiceNumber.nc', params.invoiceNumberNc) + } + if (params.statusEq) url.searchParams.set('status.eq', params.statusEq) + if (params.statusOneOf) url.searchParams.set('status.oneOf', params.statusOneOf) + if (params.statusNoneOf) url.searchParams.set('status.noneOf', params.statusNoneOf) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const invoices = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + invoices: invoices.map(mapInvoice), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + invoices: { + type: 'array', + description: 'List of invoices', + items: { type: 'object', properties: INVOICE_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_phases.ts b/apps/sim/tools/rocketlane/list_phases.ts new file mode 100644 index 00000000000..7731f776b2e --- /dev/null +++ b/apps/sim/tools/rocketlane/list_phases.ts @@ -0,0 +1,136 @@ +import { + mapPagination, + mapPhase, + PAGINATION_OUTPUT_PROPERTIES, + PHASE_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListPhasesParams, + type RocketlanePhaseListResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListPhasesTool: ToolConfig< + RocketlaneListPhasesParams, + RocketlanePhaseListResponse +> = { + id: 'rocketlane_list_phases', + name: 'Rocketlane List Phases', + description: + 'List phases of a Rocketlane project, with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project to list phases for', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of phases per page', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token returned by a previous request (valid for 15 minutes)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra phase properties to include in the response (supported: startDateActual, dueDateActual)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all phase properties in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Property to sort by (phaseName, startDate, dueDate, startDateActual, dueDateActual)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order (ASC or DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Whether results must match all filters or any filter (all or any)', + }, + phaseName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by exact phase name', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/phases`) + url.searchParams.set('projectId', String(params.projectId)) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.phaseName) url.searchParams.set('phaseName.eq', params.phaseName) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const phases = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + phases: phases.map(mapPhase), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + phases: { + type: 'array', + description: 'List of phases', + items: { type: 'object', properties: PHASE_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_placeholders.ts b/apps/sim/tools/rocketlane/list_placeholders.ts new file mode 100644 index 00000000000..6d1b2573f4b --- /dev/null +++ b/apps/sim/tools/rocketlane/list_placeholders.ts @@ -0,0 +1,71 @@ +import { + mapPagination, + mapPlaceholder, + PAGINATION_OUTPUT_PROPERTIES, + PLACEHOLDER_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListPlaceholdersParams, + type RocketlanePlaceholderListResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListPlaceholdersTool: ToolConfig< + RocketlaneListPlaceholdersParams, + RocketlanePlaceholderListResponse +> = { + id: 'rocketlane_list_placeholders', + name: 'Rocketlane List Placeholders', + description: 'List the placeholders of a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/get-placeholders`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { + placeholders: Array.isArray(data?.data) ? data.data.map(mapPlaceholder) : [], + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + placeholders: { + type: 'array', + description: 'Placeholders of the project', + items: { type: 'object', properties: PLACEHOLDER_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for fetching further pages', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_projects.ts b/apps/sim/tools/rocketlane/list_projects.ts new file mode 100644 index 00000000000..1b587f27ff9 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_projects.ts @@ -0,0 +1,224 @@ +import { + mapPagination, + mapProject, + PAGINATION_OUTPUT_PROPERTIES, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListProjectsParams, + type RocketlaneProjectListResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListProjectsTool: ToolConfig< + RocketlaneListProjectsParams, + RocketlaneProjectListResponse +> = { + id: 'rocketlane_list_projects', + name: 'Rocketlane List Projects', + description: + 'List Rocketlane projects with optional filters, sorting, and token-based pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of projects per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to return in the response (e.g. budgetedHours,progressPercentage)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response body', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Field to sort by: projectName, startDate, dueDate, startDateActual, dueDateActual, annualizedRecurringRevenue, or projectFee', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Combine filters with AND (all) or OR (any); defaults to all', + }, + projectNameContains: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose name contains this value', + }, + projectNameEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose name exactly matches this value', + }, + statusEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects with this status value', + }, + statusOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated status values; returns projects matching any of them', + }, + customerIdEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects for this customer company ID', + }, + customerIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated customer company IDs; returns projects matching any of them', + }, + teamMemberIdEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects that include this team member ID', + }, + contractTypeEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only return projects with this contract type: FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE', + }, + includeArchived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include archived projects in the results', + }, + externalReferenceIdEquals: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects with this external reference ID', + }, + startDateAfter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose start date is after this date (YYYY-MM-DD)', + }, + startDateBefore: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose start date is before this date (YYYY-MM-DD)', + }, + dueDateAfter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose due date is after this date (YYYY-MM-DD)', + }, + dueDateBefore: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return projects whose due date is before this date (YYYY-MM-DD)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/projects`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.projectNameContains) + url.searchParams.set('projectName.cn', params.projectNameContains) + if (params.projectNameEquals) url.searchParams.set('projectName.eq', params.projectNameEquals) + if (params.statusEquals) url.searchParams.set('status.eq', params.statusEquals) + if (params.statusOneOf) url.searchParams.set('status.oneOf', params.statusOneOf) + if (params.customerIdEquals) url.searchParams.set('customerId.eq', params.customerIdEquals) + if (params.customerIdOneOf) url.searchParams.set('customerId.oneOf', params.customerIdOneOf) + if (params.teamMemberIdEquals) + url.searchParams.set('teamMemberId.eq', params.teamMemberIdEquals) + if (params.contractTypeEquals) + url.searchParams.set('contractType.eq', params.contractTypeEquals) + if (params.includeArchived != null) + url.searchParams.set('includeArchive.eq', String(params.includeArchived)) + if (params.externalReferenceIdEquals) + url.searchParams.set('externalReferenceId.eq', params.externalReferenceIdEquals) + if (params.startDateAfter) url.searchParams.set('startDate.gt', params.startDateAfter) + if (params.startDateBefore) url.searchParams.set('startDate.lt', params.startDateBefore) + if (params.dueDateAfter) url.searchParams.set('dueDate.gt', params.dueDateAfter) + if (params.dueDateBefore) url.searchParams.set('dueDate.lt', params.dueDateBefore) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { + projects: Array.isArray(data?.data) ? data.data.map(mapProject) : [], + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + projects: { + type: 'array', + description: 'List of projects', + items: { type: 'object', properties: PROJECT_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for fetching further pages', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_resource_allocations.ts b/apps/sim/tools/rocketlane/list_resource_allocations.ts new file mode 100644 index 00000000000..33c5edf8516 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_resource_allocations.ts @@ -0,0 +1,209 @@ +import { + mapPagination, + mapResourceAllocation, + PAGINATION_OUTPUT_PROPERTIES, + RESOURCE_ALLOCATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneResourceAllocationListParams, + type RocketlaneResourceAllocationListResponse, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListResourceAllocationsTool: ToolConfig< + RocketlaneResourceAllocationListParams, + RocketlaneResourceAllocationListResponse +> = { + id: 'rocketlane_list_resource_allocations', + name: 'Rocketlane List Resource Allocations', + description: + 'List resource allocations in Rocketlane within a date range, with optional member, project, and placeholder filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Return allocations that start on or after this date (YYYY-MM-DD)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Return allocations that end on or before this date (YYYY-MM-DD)', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of allocations per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Optional fields to include in the response: member, task, placeholder, duration', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by: startDate, endDate, allocationType, or allocationFor', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Combine filters with AND (all) or OR (any); defaults to all', + }, + memberIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return allocations that exactly match this member ID', + }, + memberIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated member IDs to match any of', + }, + memberIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated member IDs to exclude', + }, + projectIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return allocations that exactly match this project ID', + }, + projectIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated project IDs to match any of', + }, + projectIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated project IDs to exclude', + }, + placeholderIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return allocations that exactly match this placeholder ID', + }, + placeholderIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated placeholder IDs to match any of', + }, + placeholderIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated placeholder IDs to exclude', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/resource-allocations`) + url.searchParams.set('startDate', params.startDate) + url.searchParams.set('endDate', params.endDate) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.memberIdEq) url.searchParams.set('memberId.eq', params.memberIdEq) + if (params.memberIdOneOf) url.searchParams.set('memberId.oneOf', params.memberIdOneOf) + if (params.memberIdNoneOf) url.searchParams.set('memberId.noneOf', params.memberIdNoneOf) + if (params.projectIdEq) url.searchParams.set('projectId.eq', params.projectIdEq) + if (params.projectIdOneOf) url.searchParams.set('projectId.oneOf', params.projectIdOneOf) + if (params.projectIdNoneOf) { + url.searchParams.set('projectId.noneOf', params.projectIdNoneOf) + } + if (params.placeholderIdEq) { + url.searchParams.set('placeholderId.eq', params.placeholderIdEq) + } + if (params.placeholderIdOneOf) { + url.searchParams.set('placeholderId.oneOf', params.placeholderIdOneOf) + } + if (params.placeholderIdNoneOf) { + url.searchParams.set('placeholderId.noneOf', params.placeholderIdNoneOf) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const allocations = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + allocations: allocations.map(mapResourceAllocation), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + allocations: { + type: 'array', + description: 'List of resource allocations', + items: { type: 'object', properties: RESOURCE_ALLOCATION_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_space_documents.ts b/apps/sim/tools/rocketlane/list_space_documents.ts new file mode 100644 index 00000000000..58c122abec4 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_space_documents.ts @@ -0,0 +1,222 @@ +import { + mapPagination, + mapSpaceDocument, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListSpaceDocumentsParams, + type RocketlaneListSpaceDocumentsResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_DOCUMENT_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListSpaceDocumentsTool: ToolConfig< + RocketlaneListSpaceDocumentsParams, + RocketlaneListSpaceDocumentsResponse +> = { + id: 'rocketlane_list_space_documents', + name: 'Rocketlane List Space Documents', + description: + 'List space documents in a Rocketlane project, with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project whose space documents to list', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of space documents per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous request (valid for 15 minutes)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by (spaceTabName)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How to combine filters: all (AND) or any (OR); defaults to all', + }, + spaceDocumentNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents whose name exactly matches this value', + }, + spaceDocumentNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents whose name contains this value', + }, + spaceDocumentNameNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude space documents whose name contains this value', + }, + spaceIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents in the space with this ID', + }, + createdAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents created after this time (epoch millis)', + }, + createdAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents created at exactly this time (epoch millis)', + }, + createdAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents created before this time (epoch millis)', + }, + createdAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents created at or after this time (epoch millis)', + }, + createdAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents created at or before this time (epoch millis)', + }, + updatedAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents updated after this time (epoch millis)', + }, + updatedAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents updated at exactly this time (epoch millis)', + }, + updatedAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents updated before this time (epoch millis)', + }, + updatedAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents updated at or after this time (epoch millis)', + }, + updatedAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include space documents updated at or before this time (epoch millis)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/space-documents`) + url.searchParams.set('projectId', String(params.projectId)) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.spaceDocumentNameEq) + url.searchParams.set('spaceDocumentName.eq', params.spaceDocumentNameEq) + if (params.spaceDocumentNameCn) + url.searchParams.set('spaceDocumentName.cn', params.spaceDocumentNameCn) + if (params.spaceDocumentNameNc) + url.searchParams.set('spaceDocumentName.nc', params.spaceDocumentNameNc) + if (params.spaceIdEq != null) url.searchParams.set('spaceId.eq', String(params.spaceIdEq)) + if (params.createdAtGt != null) + url.searchParams.set('createdAt.gt', String(params.createdAtGt)) + if (params.createdAtEq != null) + url.searchParams.set('createdAt.eq', String(params.createdAtEq)) + if (params.createdAtLt != null) + url.searchParams.set('createdAt.lt', String(params.createdAtLt)) + if (params.createdAtGe != null) + url.searchParams.set('createdAt.ge', String(params.createdAtGe)) + if (params.createdAtLe != null) + url.searchParams.set('createdAt.le', String(params.createdAtLe)) + if (params.updatedAtGt != null) + url.searchParams.set('updatedAt.gt', String(params.updatedAtGt)) + if (params.updatedAtEq != null) + url.searchParams.set('updatedAt.eq', String(params.updatedAtEq)) + if (params.updatedAtLt != null) + url.searchParams.set('updatedAt.lt', String(params.updatedAtLt)) + if (params.updatedAtGe != null) + url.searchParams.set('updatedAt.ge', String(params.updatedAtGe)) + if (params.updatedAtLe != null) + url.searchParams.set('updatedAt.le', String(params.updatedAtLe)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const spaceDocuments = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + spaceDocuments: spaceDocuments.map(mapSpaceDocument), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + spaceDocuments: { + type: 'array', + description: 'List of space documents', + items: { type: 'object', properties: SPACE_DOCUMENT_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_spaces.ts b/apps/sim/tools/rocketlane/list_spaces.ts new file mode 100644 index 00000000000..d363123cba7 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_spaces.ts @@ -0,0 +1,212 @@ +import { + mapPagination, + mapSpace, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListSpacesParams, + type RocketlaneListSpacesResponse, + rocketlaneError, + rocketlaneHeaders, + SPACE_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListSpacesTool: ToolConfig< + RocketlaneListSpacesParams, + RocketlaneListSpacesResponse +> = { + id: 'rocketlane_list_spaces', + name: 'Rocketlane List Spaces', + description: + 'List spaces in a Rocketlane project, with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the project whose spaces to list', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of spaces per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous request (valid for 15 minutes)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by (spaceName)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How to combine filters: all (AND) or any (OR); defaults to all', + }, + spaceNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces whose name exactly matches this value', + }, + spaceNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces whose name contains this value', + }, + spaceNameNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude spaces whose name contains this value', + }, + createdAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces created after this time (epoch millis)', + }, + createdAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces created at exactly this time (epoch millis)', + }, + createdAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces created before this time (epoch millis)', + }, + createdAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces created at or after this time (epoch millis)', + }, + createdAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces created at or before this time (epoch millis)', + }, + updatedAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces updated after this time (epoch millis)', + }, + updatedAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces updated at exactly this time (epoch millis)', + }, + updatedAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces updated before this time (epoch millis)', + }, + updatedAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces updated at or after this time (epoch millis)', + }, + updatedAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include spaces updated at or before this time (epoch millis)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/spaces`) + url.searchParams.set('projectId', String(params.projectId)) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.spaceNameEq) url.searchParams.set('spaceName.eq', params.spaceNameEq) + if (params.spaceNameCn) url.searchParams.set('spaceName.cn', params.spaceNameCn) + if (params.spaceNameNc) url.searchParams.set('spaceName.nc', params.spaceNameNc) + if (params.createdAtGt != null) + url.searchParams.set('createdAt.gt', String(params.createdAtGt)) + if (params.createdAtEq != null) + url.searchParams.set('createdAt.eq', String(params.createdAtEq)) + if (params.createdAtLt != null) + url.searchParams.set('createdAt.lt', String(params.createdAtLt)) + if (params.createdAtGe != null) + url.searchParams.set('createdAt.ge', String(params.createdAtGe)) + if (params.createdAtLe != null) + url.searchParams.set('createdAt.le', String(params.createdAtLe)) + if (params.updatedAtGt != null) + url.searchParams.set('updatedAt.gt', String(params.updatedAtGt)) + if (params.updatedAtEq != null) + url.searchParams.set('updatedAt.eq', String(params.updatedAtEq)) + if (params.updatedAtLt != null) + url.searchParams.set('updatedAt.lt', String(params.updatedAtLt)) + if (params.updatedAtGe != null) + url.searchParams.set('updatedAt.ge', String(params.updatedAtGe)) + if (params.updatedAtLe != null) + url.searchParams.set('updatedAt.le', String(params.updatedAtLe)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const spaces = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + spaces: spaces.map(mapSpace), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + spaces: { + type: 'array', + description: 'List of spaces', + items: { type: 'object', properties: SPACE_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_tasks.ts b/apps/sim/tools/rocketlane/list_tasks.ts new file mode 100644 index 00000000000..bf719ca2ef9 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_tasks.ts @@ -0,0 +1,209 @@ +import { + mapPagination, + mapTask, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListTasksParams, + type RocketlaneTaskListResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListTasksTool: ToolConfig< + RocketlaneListTasksParams, + RocketlaneTaskListResponse +> = { + id: 'rocketlane_list_tasks', + name: 'Rocketlane List Tasks', + description: 'Retrieve all Rocketlane tasks with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of tasks per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Token pointing to the next page of results, from a previous response', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Extra fields to include in the response (startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId)', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Field to sort by: taskName, startDate, dueDate, startDateActual, or dueDateActual', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How multiple filters combine: all (AND) or any (OR)', + }, + projectId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by project ID', + }, + phaseId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by phase ID', + }, + taskName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks whose name exactly matches this value', + }, + taskNameContains: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks whose name contains this value', + }, + taskStatus: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by task status value', + }, + startDateFrom: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks with a start date on or after this date (YYYY-MM-DD)', + }, + startDateTo: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks with a start date on or before this date (YYYY-MM-DD)', + }, + dueDateFrom: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks with a due date on or after this date (YYYY-MM-DD)', + }, + dueDateTo: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks with a due date on or before this date (YYYY-MM-DD)', + }, + includeArchive: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether archived tasks should be included in the results', + }, + externalReferenceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter tasks by external reference identifier', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/tasks`) + if (params.pageSize !== undefined) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields && params.includeFields.length > 0) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields !== undefined) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.projectId !== undefined) { + url.searchParams.set('projectId.eq', String(params.projectId)) + } + if (params.phaseId !== undefined) { + url.searchParams.set('phaseId.eq', String(params.phaseId)) + } + if (params.taskName) url.searchParams.set('taskName.eq', params.taskName) + if (params.taskNameContains) url.searchParams.set('taskName.cn', params.taskNameContains) + if (params.taskStatus) url.searchParams.set('task.status.eq', params.taskStatus) + if (params.startDateFrom) url.searchParams.set('startDate.ge', params.startDateFrom) + if (params.startDateTo) url.searchParams.set('startDate.le', params.startDateTo) + if (params.dueDateFrom) url.searchParams.set('dueDate.ge', params.dueDateFrom) + if (params.dueDateTo) url.searchParams.set('dueDate.le', params.dueDateTo) + if (params.includeArchive !== undefined) { + url.searchParams.set('includeArchive.eq', String(params.includeArchive)) + } + if (params.externalReferenceId) { + url.searchParams.set('externalReferenceId.eq', params.externalReferenceId) + } + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const tasks = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + tasks: tasks.map(mapTask), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + tasks: { + type: 'array', + description: 'List of tasks matching the filters', + items: { type: 'object', properties: TASK_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_time_entries.ts b/apps/sim/tools/rocketlane/list_time_entries.ts new file mode 100644 index 00000000000..bf9794cb92b --- /dev/null +++ b/apps/sim/tools/rocketlane/list_time_entries.ts @@ -0,0 +1,315 @@ +import { + mapPagination, + mapTimeEntry, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListTimeEntriesParams, + type RocketlaneTimeEntryListResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListTimeEntriesTool: ToolConfig< + RocketlaneListTimeEntriesParams, + RocketlaneTimeEntryListResponse +> = { + id: 'rocketlane_list_time_entries', + name: 'Rocketlane List Time Entries', + description: + 'List Rocketlane time entries with optional filters, sorting, and cursor-based pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + dateEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on this exact date (YYYY-MM-DD)', + }, + dateGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries after this date (YYYY-MM-DD)', + }, + dateGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on or after this date (YYYY-MM-DD)', + }, + dateLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries before this date (YYYY-MM-DD)', + }, + dateLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on or before this date (YYYY-MM-DD)', + }, + projectIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this project ID', + }, + taskIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this task ID', + }, + projectPhaseIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this project phase ID', + }, + categoryIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries with this category ID', + }, + userIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries belonging to this user ID', + }, + emailIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries belonging to the user with this exact email', + }, + emailIdCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries whose user email contains this text', + }, + sourceTypeEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only entries with this source type (GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE)', + }, + activityNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries with this exact activity name', + }, + activityNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries whose activity name contains this text', + }, + approvalStatusEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only entries with this approval status (NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED)', + }, + billableEq: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only billable (true) or non-billable (false) entries', + }, + includeDeletedEq: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether deleted time entries are included in the response', + }, + submittedByEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries submitted by this user ID', + }, + approvedByEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries approved by this user ID', + }, + rejectedByEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries rejected by this user ID', + }, + createdAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries created after this epoch-millisecond timestamp', + }, + createdAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries created before this epoch-millisecond timestamp', + }, + updatedAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries updated after this epoch-millisecond timestamp', + }, + updatedAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries updated before this epoch-millisecond timestamp', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How to combine filters: all (AND, default) or any (OR)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by (minutes, date, id, billable)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (default DESC)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include in the response (notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate)', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of entries per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response for fetching the next page', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-entries`) + if (params.dateEq) url.searchParams.set('date.eq', params.dateEq) + if (params.dateGt) url.searchParams.set('date.gt', params.dateGt) + if (params.dateGe) url.searchParams.set('date.ge', params.dateGe) + if (params.dateLt) url.searchParams.set('date.lt', params.dateLt) + if (params.dateLe) url.searchParams.set('date.le', params.dateLe) + if (params.projectIdEq != null) { + url.searchParams.set('projectId.eq', String(params.projectIdEq)) + } + if (params.taskIdEq != null) url.searchParams.set('taskId.eq', String(params.taskIdEq)) + if (params.projectPhaseIdEq != null) { + url.searchParams.set('projectPhase.eq', String(params.projectPhaseIdEq)) + } + if (params.categoryIdEq != null) { + url.searchParams.set('category.eq', String(params.categoryIdEq)) + } + if (params.userIdEq != null) url.searchParams.set('user.eq', String(params.userIdEq)) + if (params.emailIdEq) url.searchParams.set('emailId.eq', params.emailIdEq) + if (params.emailIdCn) url.searchParams.set('emailId.cn', params.emailIdCn) + if (params.sourceTypeEq) url.searchParams.set('sourceType.eq', params.sourceTypeEq) + if (params.activityNameEq) url.searchParams.set('activityName.eq', params.activityNameEq) + if (params.activityNameCn) url.searchParams.set('activityName.cn', params.activityNameCn) + if (params.approvalStatusEq) { + url.searchParams.set('approvalStatus.eq', params.approvalStatusEq) + } + if (params.billableEq != null) { + url.searchParams.set('billable.eq', String(params.billableEq)) + } + if (params.includeDeletedEq != null) { + url.searchParams.set('includeDeleted.eq', String(params.includeDeletedEq)) + } + if (params.submittedByEq != null) { + url.searchParams.set('submittedBy.eq', String(params.submittedByEq)) + } + if (params.approvedByEq != null) { + url.searchParams.set('approvedBy.eq', String(params.approvedByEq)) + } + if (params.rejectedByEq != null) { + url.searchParams.set('rejectedBy.eq', String(params.rejectedByEq)) + } + if (params.createdAtGt != null) { + url.searchParams.set('createdAt.gt', String(params.createdAtGt)) + } + if (params.createdAtLt != null) { + url.searchParams.set('createdAt.lt', String(params.createdAtLt)) + } + if (params.updatedAtGt != null) { + url.searchParams.set('updatedAt.gt', String(params.updatedAtGt)) + } + if (params.updatedAtLt != null) { + url.searchParams.set('updatedAt.lt', String(params.updatedAtLt)) + } + if (params.match) url.searchParams.set('match', params.match) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const entries = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + timeEntries: entries.map(mapTimeEntry), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + timeEntries: { + type: 'array', + description: 'List of time entries matching the filters', + items: { type: 'object', properties: TIME_ENTRY_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_time_entry_categories.ts b/apps/sim/tools/rocketlane/list_time_entry_categories.ts new file mode 100644 index 00000000000..e65c751b37c --- /dev/null +++ b/apps/sim/tools/rocketlane/list_time_entry_categories.ts @@ -0,0 +1,89 @@ +import { + mapPagination, + mapTimeEntryCategory, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListTimeEntryCategoriesParams, + type RocketlaneTimeEntryCategory, + type RocketlaneTimeEntryCategoryListResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_CATEGORY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListTimeEntryCategoriesTool: ToolConfig< + RocketlaneListTimeEntryCategoriesParams, + RocketlaneTimeEntryCategoryListResponse +> = { + id: 'rocketlane_list_time_entry_categories', + name: 'Rocketlane List Time Entry Categories', + description: 'List the time entry categories configured in Rocketlane', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of categories per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response for fetching the next page', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-entries/categories`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const rawCategories = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + categories: rawCategories + .map(mapTimeEntryCategory) + .filter( + ( + category: RocketlaneTimeEntryCategory | null + ): category is RocketlaneTimeEntryCategory => category !== null + ), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + categories: { + type: 'array', + description: 'List of time entry categories', + items: { type: 'object', properties: TIME_ENTRY_CATEGORY_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_time_offs.ts b/apps/sim/tools/rocketlane/list_time_offs.ts new file mode 100644 index 00000000000..19aac6a4682 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_time_offs.ts @@ -0,0 +1,259 @@ +import { + mapPagination, + mapTimeOff, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneTimeOffListParams, + type RocketlaneTimeOffListResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_OFF_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListTimeOffsTool: ToolConfig< + RocketlaneTimeOffListParams, + RocketlaneTimeOffListResponse +> = { + id: 'rocketlane_list_time_offs', + name: 'Rocketlane List Time-Offs', + description: + 'List time-offs in Rocketlane with optional date, type, and user filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of time-offs per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response (valid for 15 minutes)', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Optional fields to include in the response: note, notifyUsers', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by: startDate, endDate, or createdAt', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Combine filters with AND (all) or OR (any); defaults to all', + }, + startDateGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with start dates greater than this date (YYYY-MM-DD)', + }, + startDateEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with start dates equal to this date (YYYY-MM-DD)', + }, + startDateLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with start dates lesser than this date (YYYY-MM-DD)', + }, + startDateGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Return time-offs with start dates greater than or equal to this date (YYYY-MM-DD)', + }, + startDateLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Return time-offs with start dates lesser than or equal to this date (YYYY-MM-DD)', + }, + endDateGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with end dates greater than this date (YYYY-MM-DD)', + }, + endDateEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with end dates equal to this date (YYYY-MM-DD)', + }, + endDateLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with end dates lesser than this date (YYYY-MM-DD)', + }, + endDateGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Return time-offs with end dates greater than or equal to this date (YYYY-MM-DD)', + }, + endDateLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs with end dates lesser than or equal to this date (YYYY-MM-DD)', + }, + typeEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs matching this type: FULL_DAY, HALF_DAY, or CUSTOM', + }, + typeOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated time-off types to match any of (FULL_DAY, HALF_DAY, CUSTOM)', + }, + typeNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated time-off types to match none of (FULL_DAY, HALF_DAY, CUSTOM)', + }, + userIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs that exactly match this user ID', + }, + userIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user IDs to match any of', + }, + userIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user IDs to match none of', + }, + emailIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Return time-offs that exactly match this user email', + }, + emailIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user emails to match any of', + }, + emailIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated user emails to match none of', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-offs`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields?.length) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.startDateGt) url.searchParams.set('startDate.gt', params.startDateGt) + if (params.startDateEq) url.searchParams.set('startDate.eq', params.startDateEq) + if (params.startDateLt) url.searchParams.set('startDate.lt', params.startDateLt) + if (params.startDateGe) url.searchParams.set('startDate.ge', params.startDateGe) + if (params.startDateLe) url.searchParams.set('startDate.le', params.startDateLe) + if (params.endDateGt) url.searchParams.set('endDate.gt', params.endDateGt) + if (params.endDateEq) url.searchParams.set('endDate.eq', params.endDateEq) + if (params.endDateLt) url.searchParams.set('endDate.lt', params.endDateLt) + if (params.endDateGe) url.searchParams.set('endDate.ge', params.endDateGe) + if (params.endDateLe) url.searchParams.set('endDate.le', params.endDateLe) + if (params.typeEq) url.searchParams.set('type.eq', params.typeEq) + if (params.typeOneOf) url.searchParams.set('type.oneOf', params.typeOneOf) + if (params.typeNoneOf) url.searchParams.set('type.noneOf', params.typeNoneOf) + if (params.userIdEq) url.searchParams.set('userId.eq', params.userIdEq) + if (params.userIdOneOf) url.searchParams.set('userId.oneOf', params.userIdOneOf) + if (params.userIdNoneOf) url.searchParams.set('userId.noneOf', params.userIdNoneOf) + if (params.emailIdEq) url.searchParams.set('emailId.eq', params.emailIdEq) + if (params.emailIdOneOf) url.searchParams.set('emailId.oneOf', params.emailIdOneOf) + if (params.emailIdNoneOf) url.searchParams.set('emailId.noneOf', params.emailIdNoneOf) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const timeOffs = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + timeOffs: timeOffs.map(mapTimeOff), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + timeOffs: { + type: 'array', + description: 'List of time-offs', + items: { type: 'object', properties: TIME_OFF_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/list_users.ts b/apps/sim/tools/rocketlane/list_users.ts new file mode 100644 index 00000000000..e8c2401f7d4 --- /dev/null +++ b/apps/sim/tools/rocketlane/list_users.ts @@ -0,0 +1,389 @@ +import { + mapPagination, + mapUser, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneListUsersParams, + type RocketlaneListUsersResponse, + rocketlaneError, + rocketlaneHeaders, + USER_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneListUsersTool: ToolConfig< + RocketlaneListUsersParams, + RocketlaneListUsersResponse +> = { + id: 'rocketlane_list_users', + name: 'Rocketlane List Users', + description: + 'List users in your Rocketlane account, with optional filters, sorting, and pagination', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of users per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous request (valid for 15 minutes)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include: role, company, permission, holidayCalendar, capacityInMinutes, profilePictureUrl', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include all fields in the response', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Field to sort by: email, firstName, lastName, type, status, or capacityInMinutes', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (defaults to DESC)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How to combine filters: all (AND) or any (OR); defaults to all', + }, + firstNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose first name exactly matches this value', + }, + firstNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose first name contains this value', + }, + firstNameNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude users whose first name contains this value', + }, + lastNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose last name exactly matches this value', + }, + lastNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose last name contains this value', + }, + lastNameNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude users whose last name contains this value', + }, + emailEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose email exactly matches this value', + }, + emailCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose email contains this value', + }, + emailNc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exclude users whose email contains this value', + }, + statusEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users with this status: INACTIVE, INVITED, ACTIVE, or PASSIVE', + }, + statusOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated statuses; only include users matching one of them (INACTIVE, INVITED, ACTIVE, PASSIVE)', + }, + statusNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated statuses; exclude users matching any of them (INACTIVE, INVITED, ACTIVE, PASSIVE)', + }, + typeEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only include users of this type: TEAM_MEMBER, PARTNER, CUSTOMER, or EXTERNAL_PARTNER', + }, + typeOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated types; only include users matching one of them (TEAM_MEMBER, PARTNER, CUSTOMER, EXTERNAL_PARTNER)', + }, + roleIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users with this role ID', + }, + roleIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated role IDs; only include users matching one of them', + }, + roleIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated role IDs; exclude users matching any of them', + }, + permissionIdEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include users with this permission ID', + }, + permissionIdOneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated permission IDs; only include users matching one of them', + }, + permissionIdNoneOf: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated permission IDs; exclude users matching any of them', + }, + capacityInMinutesEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose capacity in minutes equals this value', + }, + capacityInMinutesGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose capacity in minutes is greater than this value', + }, + capacityInMinutesGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Only include users whose capacity in minutes is greater than or equal to this value', + }, + capacityInMinutesLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users whose capacity in minutes is less than this value', + }, + capacityInMinutesLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Only include users whose capacity in minutes is less than or equal to this value', + }, + createdAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users created after this time (epoch millis)', + }, + createdAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users created at exactly this time (epoch millis)', + }, + createdAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users created before this time (epoch millis)', + }, + createdAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users created at or after this time (epoch millis)', + }, + createdAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users created at or before this time (epoch millis)', + }, + updatedAtGt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users updated after this time (epoch millis)', + }, + updatedAtEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users updated at exactly this time (epoch millis)', + }, + updatedAtLt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users updated before this time (epoch millis)', + }, + updatedAtGe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users updated at or after this time (epoch millis)', + }, + updatedAtLe: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only include users updated at or before this time (epoch millis)', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/users`) + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.match) url.searchParams.set('match', params.match) + if (params.firstNameEq) url.searchParams.set('firstName.eq', params.firstNameEq) + if (params.firstNameCn) url.searchParams.set('firstName.cn', params.firstNameCn) + if (params.firstNameNc) url.searchParams.set('firstName.nc', params.firstNameNc) + if (params.lastNameEq) url.searchParams.set('lastName.eq', params.lastNameEq) + if (params.lastNameCn) url.searchParams.set('lastName.cn', params.lastNameCn) + if (params.lastNameNc) url.searchParams.set('lastName.nc', params.lastNameNc) + if (params.emailEq) url.searchParams.set('email.eq', params.emailEq) + if (params.emailCn) url.searchParams.set('email.cn', params.emailCn) + if (params.emailNc) url.searchParams.set('email.nc', params.emailNc) + if (params.statusEq) url.searchParams.set('status.eq', params.statusEq) + if (params.statusOneOf) url.searchParams.set('status.oneOf', params.statusOneOf) + if (params.statusNoneOf) url.searchParams.set('status.noneOf', params.statusNoneOf) + if (params.typeEq) url.searchParams.set('type.eq', params.typeEq) + if (params.typeOneOf) url.searchParams.set('type.oneOf', params.typeOneOf) + if (params.roleIdEq) url.searchParams.set('roleId.eq', params.roleIdEq) + if (params.roleIdOneOf) url.searchParams.set('roleId.oneOf', params.roleIdOneOf) + if (params.roleIdNoneOf) url.searchParams.set('roleId.noneOf', params.roleIdNoneOf) + if (params.permissionIdEq) url.searchParams.set('permissionId.eq', params.permissionIdEq) + if (params.permissionIdOneOf) + url.searchParams.set('permissionId.oneOf', params.permissionIdOneOf) + if (params.permissionIdNoneOf) + url.searchParams.set('permissionId.noneOf', params.permissionIdNoneOf) + if (params.capacityInMinutesEq != null) + url.searchParams.set('capacityInMinutes.eq', String(params.capacityInMinutesEq)) + if (params.capacityInMinutesGt != null) + url.searchParams.set('capacityInMinutes.gt', String(params.capacityInMinutesGt)) + if (params.capacityInMinutesGe != null) + url.searchParams.set('capacityInMinutes.ge', String(params.capacityInMinutesGe)) + if (params.capacityInMinutesLt != null) + url.searchParams.set('capacityInMinutes.lt', String(params.capacityInMinutesLt)) + if (params.capacityInMinutesLe != null) + url.searchParams.set('capacityInMinutes.le', String(params.capacityInMinutesLe)) + if (params.createdAtGt != null) + url.searchParams.set('createdAt.gt', String(params.createdAtGt)) + if (params.createdAtEq != null) + url.searchParams.set('createdAt.eq', String(params.createdAtEq)) + if (params.createdAtLt != null) + url.searchParams.set('createdAt.lt', String(params.createdAtLt)) + if (params.createdAtGe != null) + url.searchParams.set('createdAt.ge', String(params.createdAtGe)) + if (params.createdAtLe != null) + url.searchParams.set('createdAt.le', String(params.createdAtLe)) + if (params.updatedAtGt != null) + url.searchParams.set('updatedAt.gt', String(params.updatedAtGt)) + if (params.updatedAtEq != null) + url.searchParams.set('updatedAt.eq', String(params.updatedAtEq)) + if (params.updatedAtLt != null) + url.searchParams.set('updatedAt.lt', String(params.updatedAtLt)) + if (params.updatedAtGe != null) + url.searchParams.set('updatedAt.ge', String(params.updatedAtGe)) + if (params.updatedAtLe != null) + url.searchParams.set('updatedAt.le', String(params.updatedAtLe)) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const users = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + users: users.map(mapUser), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + users: { + type: 'array', + description: 'List of users', + items: { type: 'object', properties: USER_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/move_task_to_phase.ts b/apps/sim/tools/rocketlane/move_task_to_phase.ts new file mode 100644 index 00000000000..7c211906bd4 --- /dev/null +++ b/apps/sim/tools/rocketlane/move_task_to_phase.ts @@ -0,0 +1,70 @@ +import { + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneMoveTaskToPhaseParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneMoveTaskToPhaseTool: ToolConfig< + RocketlaneMoveTaskToPhaseParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_move_task_to_phase', + name: 'Rocketlane Move Task To Phase', + description: 'Move a Rocketlane task to a given phase, associating the task with that phase', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to move', + }, + phaseId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the phase to move the task to', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/move-phase`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + phase: { phaseId: params.phaseId }, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated phase', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/remove_project_members.ts b/apps/sim/tools/rocketlane/remove_project_members.ts new file mode 100644 index 00000000000..669383a9a47 --- /dev/null +++ b/apps/sim/tools/rocketlane/remove_project_members.ts @@ -0,0 +1,84 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneProjectResponse, + type RocketlaneRemoveProjectMembersParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneRemoveProjectMembersTool: ToolConfig< + RocketlaneRemoveProjectMembersParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_remove_project_members', + name: 'Rocketlane Remove Project Members', + description: 'Remove team members from a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'User IDs of team members to remove (at least one of memberUserIds or memberEmailIds is required)', + items: { type: 'number', description: 'User ID of a team member' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Emails of team members to remove (at least one of memberUserIds or memberEmailIds is required)', + items: { type: 'string', description: 'Email of a team member' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/remove-members`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const members = [ + ...(params.memberUserIds ?? []).map((userId) => ({ userId })), + ...(params.memberEmailIds ?? []).map((emailId) => ({ emailId })), + ] + return { members } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The project with its updated team members', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/remove_task_assignees.ts b/apps/sim/tools/rocketlane/remove_task_assignees.ts new file mode 100644 index 00000000000..c184e7e5433 --- /dev/null +++ b/apps/sim/tools/rocketlane/remove_task_assignees.ts @@ -0,0 +1,79 @@ +import { + buildTaskMembers, + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskAssigneesParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneRemoveTaskAssigneesTool: ToolConfig< + RocketlaneTaskAssigneesParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_remove_task_assignees', + name: 'Rocketlane Remove Task Assignees', + description: 'Remove assignees from a Rocketlane task', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to remove assignees from', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to remove from the assignees', + items: { type: 'number' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to remove from the assignees', + items: { type: 'string' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/remove-assignees`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + members: buildTaskMembers(params.memberUserIds, params.memberEmailIds), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated assignees', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/remove_task_dependencies.ts b/apps/sim/tools/rocketlane/remove_task_dependencies.ts new file mode 100644 index 00000000000..7e79e9713d0 --- /dev/null +++ b/apps/sim/tools/rocketlane/remove_task_dependencies.ts @@ -0,0 +1,71 @@ +import { + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskDependenciesParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneRemoveTaskDependenciesTool: ToolConfig< + RocketlaneTaskDependenciesParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_remove_task_dependencies', + name: 'Rocketlane Remove Task Dependencies', + description: 'Remove dependencies from a Rocketlane task', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to remove dependencies from', + }, + dependencyTaskIds: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Task IDs to remove from the task dependencies', + items: { type: 'number' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/remove-dependencies`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + dependencies: params.dependencyTaskIds.map((taskId) => ({ taskId })), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated dependencies', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/remove_task_followers.ts b/apps/sim/tools/rocketlane/remove_task_followers.ts new file mode 100644 index 00000000000..393444ef6ce --- /dev/null +++ b/apps/sim/tools/rocketlane/remove_task_followers.ts @@ -0,0 +1,79 @@ +import { + buildTaskMembers, + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskFollowersParams, + type RocketlaneTaskResponse, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneRemoveTaskFollowersTool: ToolConfig< + RocketlaneTaskFollowersParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_remove_task_followers', + name: 'Rocketlane Remove Task Followers', + description: 'Remove followers from a Rocketlane task', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to remove followers from', + }, + memberUserIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'User IDs of members to remove from the followers', + items: { type: 'number' }, + }, + memberEmailIds: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Email addresses of members to remove from the followers', + items: { type: 'string' }, + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}/remove-followers`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + members: buildTaskMembers(params.memberUserIds, params.memberEmailIds), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The task with its updated followers', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/search_time_entries.ts b/apps/sim/tools/rocketlane/search_time_entries.ts new file mode 100644 index 00000000000..8781269469f --- /dev/null +++ b/apps/sim/tools/rocketlane/search_time_entries.ts @@ -0,0 +1,227 @@ +import { + mapPagination, + mapTimeEntry, + PAGINATION_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneSearchTimeEntriesParams, + type RocketlaneTimeEntryListResponse, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneSearchTimeEntriesTool: ToolConfig< + RocketlaneSearchTimeEntriesParams, + RocketlaneTimeEntryListResponse +> = { + id: 'rocketlane_search_time_entries', + name: 'Rocketlane Search Time Entries', + description: + 'Search Rocketlane time entries with filters, sorting, and pagination (deprecated by Rocketlane in favor of listing time entries with filters)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + dateEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on this exact date (YYYY-MM-DD)', + }, + dateGt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries after this date (YYYY-MM-DD)', + }, + dateGe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on or after this date (YYYY-MM-DD)', + }, + dateLt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries before this date (YYYY-MM-DD)', + }, + dateLe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries on or before this date (YYYY-MM-DD)', + }, + projectEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this project ID', + }, + taskEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this task ID', + }, + projectPhaseIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries for this project phase ID', + }, + categoryIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries with this category ID', + }, + userIdEq: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Only entries belonging to this user ID', + }, + sourceTypeEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only entries with this source type (GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE)', + }, + activityNameEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries with this exact activity name', + }, + activityNameCn: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only entries whose activity name contains this text', + }, + approvalStatusEq: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Only entries with this approval status (NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED)', + }, + match: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'How to combine filters: all (AND, default) or any (OR)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by (minutes, date, id, billable)', + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: ASC or DESC (default DESC)', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include in the response (notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of entries per page (defaults to 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token from a previous response for fetching the next page', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${ROCKETLANE_API_BASE}/time-entries/search`) + if (params.dateEq) url.searchParams.set('date.eq', params.dateEq) + if (params.dateGt) url.searchParams.set('date.gt', params.dateGt) + if (params.dateGe) url.searchParams.set('date.ge', params.dateGe) + if (params.dateLt) url.searchParams.set('date.lt', params.dateLt) + if (params.dateLe) url.searchParams.set('date.le', params.dateLe) + if (params.projectEq != null) url.searchParams.set('project.eq', String(params.projectEq)) + if (params.taskEq != null) url.searchParams.set('task.eq', String(params.taskEq)) + if (params.projectPhaseIdEq != null) { + url.searchParams.set('projectPhase.eq', String(params.projectPhaseIdEq)) + } + if (params.categoryIdEq != null) { + url.searchParams.set('category.eq', String(params.categoryIdEq)) + } + if (params.userIdEq != null) url.searchParams.set('user.eq', String(params.userIdEq)) + if (params.sourceTypeEq) url.searchParams.set('sourceType.eq', params.sourceTypeEq) + if (params.activityNameEq) url.searchParams.set('activityName.eq', params.activityNameEq) + if (params.activityNameCn) url.searchParams.set('activityName.cn', params.activityNameCn) + if (params.approvalStatusEq) { + url.searchParams.set('approvalStatus.eq', params.approvalStatusEq) + } + if (params.match) url.searchParams.set('match', params.match) + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy) + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + if (params.pageSize != null) url.searchParams.set('pageSize', String(params.pageSize)) + if (params.pageToken) url.searchParams.set('pageToken', params.pageToken) + return url.toString() + }, + method: 'GET', + headers: (params) => rocketlaneHeaders(params.apiKey), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + const entries = Array.isArray(data?.data) ? data.data : [] + return { + success: true, + output: { + timeEntries: entries.map(mapTimeEntry), + pagination: mapPagination(data?.pagination), + }, + } + }, + + outputs: { + timeEntries: { + type: 'array', + description: 'List of time entries matching the search filters', + items: { type: 'object', properties: TIME_ENTRY_OUTPUT_PROPERTIES }, + }, + pagination: { + type: 'object', + description: 'Pagination details for the result set', + properties: PAGINATION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/types.ts b/apps/sim/tools/rocketlane/types.ts new file mode 100644 index 00000000000..8f7705c58a3 --- /dev/null +++ b/apps/sim/tools/rocketlane/types.ts @@ -0,0 +1,4372 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** Base URL for the Rocketlane REST API (v1.0). */ +export const ROCKETLANE_API_BASE = 'https://api.rocketlane.com/api/1.0' + +/** Every Rocketlane tool authenticates with the account API key via the `api-key` header. */ +export interface RocketlaneBaseParams { + apiKey: string +} + +/** + * Builds the standard auth headers shared by every Rocketlane request. + */ +export function rocketlaneHeaders(apiKey: string): Record { + return { + 'api-key': apiKey, + 'Content-Type': 'application/json', + } +} + +/** + * Extracts a human-readable error message from a non-OK Rocketlane response. + * Errors are returned as `{ errors: [{ errorCode, errorMessage, field }] }`. + */ +export async function rocketlaneError(response: Response): Promise { + const text = await response.text() + try { + const parsed = JSON.parse(text) + const errors = Array.isArray(parsed?.errors) ? parsed.errors : [] + const messages = errors + .map((e: { errorMessage?: string; field?: string }) => + e?.errorMessage + ? e.field + ? `${e.errorMessage} (field: ${e.field})` + : e.errorMessage + : null + ) + .filter(Boolean) + if (messages.length > 0) return messages.join('; ') + } catch { + // Fall through to the raw body. + } + return text || `Rocketlane API error (HTTP ${response.status})` +} + +type Raw = Record + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +function asBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function asObject(value: unknown): Raw | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : null +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +// region Shared object shapes + +/** Compact user reference returned inside most Rocketlane resources. */ +export interface RocketlaneUserSummary { + userId: number | null + firstName: string | null + lastName: string | null + emailId: string | null +} + +export function mapUserSummary(value: unknown): RocketlaneUserSummary | null { + const raw = asObject(value) + if (!raw) return null + return { + userId: asNumber(raw.userId), + firstName: asString(raw.firstName), + lastName: asString(raw.lastName), + emailId: asString(raw.emailId), + } +} + +export const USER_SUMMARY_OUTPUT_PROPERTIES = { + userId: { type: 'number', description: 'Unique identifier of the user', nullable: true }, + firstName: { type: 'string', description: 'First name of the user', nullable: true }, + lastName: { type: 'string', description: 'Last name of the user', nullable: true }, + emailId: { type: 'string', description: 'Email address of the user', nullable: true }, +} satisfies Record + +/** Pagination envelope returned by every Rocketlane list endpoint. */ +export interface RocketlanePagination { + pageSize: number | null + hasMore: boolean | null + totalRecordCount: number | null + nextPageToken: string | null +} + +export function mapPagination(value: unknown): RocketlanePagination { + const raw = asObject(value) ?? {} + return { + pageSize: asNumber(raw.pageSize), + hasMore: asBoolean(raw.hasMore), + totalRecordCount: asNumber(raw.totalRecordCount), + nextPageToken: asString(raw.nextPageToken), + } +} + +export const PAGINATION_OUTPUT_PROPERTIES = { + pageSize: { + type: 'number', + description: 'Page size used for the current request', + nullable: true, + }, + hasMore: { type: 'boolean', description: 'Whether more results are available', nullable: true }, + totalRecordCount: { + type: 'number', + description: 'Total number of records matching the request', + nullable: true, + }, + nextPageToken: { + type: 'string', + description: 'Token for fetching the next page (valid for 15 minutes)', + nullable: true, + }, +} satisfies Record + +// endregion + +// region Tasks + +/** Compact project reference returned inside a Rocketlane task. */ +export interface RocketlaneTaskProjectRef { + projectId: number | null + projectName: string | null +} + +/** Compact phase reference returned inside a Rocketlane task. */ +export interface RocketlaneTaskPhaseRef { + phaseId: number | null + phaseName: string | null +} + +/** Value/label pair used for task status and priority fields. */ +export interface RocketlaneTaskChoice { + value: number | null + label: string | null +} + +/** Role associated with a placeholder assignee. */ +export interface RocketlaneTaskRole { + roleId: number | null + roleName: string | null +} + +/** Placeholder assignee on a task, associated with a role. */ +export interface RocketlaneTaskPlaceholder { + placeholderId: number | null + placeholderName: string | null + role: RocketlaneTaskRole | null +} + +/** Assignees of a task: members (team members or customers) and placeholders. */ +export interface RocketlaneTaskAssignees { + members: RocketlaneUserSummary[] + placeholders: RocketlaneTaskPlaceholder[] +} + +/** Followers of a task (members only). */ +export interface RocketlaneTaskFollowers { + members: RocketlaneUserSummary[] +} + +/** Lite task reference used for dependencies and the parent task. */ +export interface RocketlaneTaskLite { + taskId: number | null + taskName: string | null +} + +/** Custom field value attached to a task. */ +export interface RocketlaneTaskField { + fieldId: number | null + fieldLabel: string | null + fieldValue: unknown + fieldValueLabel: string | null +} + +/** Time entry category associated with a task. */ +export interface RocketlaneTaskTimeEntryCategory { + categoryId: number | null + categoryName: string | null +} + +/** Financials budget in which the task's time entry is added. */ +export interface RocketlaneTaskBudget { + budgetId: number | null + budgetName: string | null +} + +/** A Rocketlane task as returned by the Tasks endpoints. */ +export interface RocketlaneTask { + taskId: number | null + taskName: string | null + taskDescription: string | null + taskPrivateNote: string | null + startDate: string | null + dueDate: string | null + startDateActual: string | null + dueDateActual: string | null + archived: boolean | null + effortInMinutes: number | null + progress: number | null + atRisk: boolean | null + type: string | null + createdAt: number | null + updatedAt: number | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + project: RocketlaneTaskProjectRef | null + phase: RocketlaneTaskPhaseRef | null + status: RocketlaneTaskChoice | null + priority: RocketlaneTaskChoice | null + fields: RocketlaneTaskField[] + assignees: RocketlaneTaskAssignees | null + followers: RocketlaneTaskFollowers | null + dependencies: RocketlaneTaskLite[] + parent: RocketlaneTaskLite | null + externalReferenceId: string | null + billable: boolean | null + timeEntryCategory: RocketlaneTaskTimeEntryCategory | null + financialsBudgets: RocketlaneTaskBudget[] + csatEnabled: boolean | null + private: boolean | null +} + +function mapTaskProjectRef(value: unknown): RocketlaneTaskProjectRef | null { + const raw = asObject(value) + if (!raw) return null + return { + projectId: asNumber(raw.projectId), + projectName: asString(raw.projectName), + } +} + +function mapTaskPhaseRef(value: unknown): RocketlaneTaskPhaseRef | null { + const raw = asObject(value) + if (!raw) return null + return { + phaseId: asNumber(raw.phaseId), + phaseName: asString(raw.phaseName), + } +} + +function mapTaskChoice(value: unknown): RocketlaneTaskChoice | null { + const raw = asObject(value) + if (!raw) return null + return { + value: asNumber(raw.value), + label: asString(raw.label), + } +} + +function mapTaskRole(value: unknown): RocketlaneTaskRole | null { + const raw = asObject(value) + if (!raw) return null + return { + roleId: asNumber(raw.roleId), + roleName: asString(raw.roleName), + } +} + +function mapTaskPlaceholder(value: unknown): RocketlaneTaskPlaceholder { + const raw = asObject(value) ?? {} + return { + placeholderId: asNumber(raw.placeholderId), + placeholderName: asString(raw.placeholderName), + role: mapTaskRole(raw.role), + } +} + +function mapTaskAssignees(value: unknown): RocketlaneTaskAssignees | null { + const raw = asObject(value) + if (!raw) return null + return { + members: asArray(raw.members) + .map(mapUserSummary) + .filter((member): member is RocketlaneUserSummary => member !== null), + placeholders: asArray(raw.placeholders).map(mapTaskPlaceholder), + } +} + +function mapTaskFollowers(value: unknown): RocketlaneTaskFollowers | null { + const raw = asObject(value) + if (!raw) return null + return { + members: asArray(raw.members) + .map(mapUserSummary) + .filter((member): member is RocketlaneUserSummary => member !== null), + } +} + +function mapTaskLite(value: unknown): RocketlaneTaskLite { + const raw = asObject(value) ?? {} + return { + taskId: asNumber(raw.taskId), + taskName: asString(raw.taskName), + } +} + +function mapTaskField(value: unknown): RocketlaneTaskField { + const raw = asObject(value) ?? {} + return { + fieldId: asNumber(raw.fieldId), + fieldLabel: asString(raw.fieldLabel), + fieldValue: raw.fieldValue ?? null, + fieldValueLabel: asString(raw.fieldValueLabel), + } +} + +function mapTaskTimeEntryCategory(value: unknown): RocketlaneTaskTimeEntryCategory | null { + const raw = asObject(value) + if (!raw) return null + return { + categoryId: asNumber(raw.categoryId), + categoryName: asString(raw.categoryName), + } +} + +function mapTaskBudget(value: unknown): RocketlaneTaskBudget { + const raw = asObject(value) ?? {} + return { + budgetId: asNumber(raw.budgetId), + budgetName: asString(raw.budgetName), + } +} + +export function mapTask(value: unknown): RocketlaneTask { + const raw = asObject(value) ?? {} + return { + taskId: asNumber(raw.taskId), + taskName: asString(raw.taskName), + taskDescription: asString(raw.taskDescription), + taskPrivateNote: asString(raw.taskPrivateNote), + startDate: asString(raw.startDate), + dueDate: asString(raw.dueDate), + startDateActual: asString(raw.startDateActual), + dueDateActual: asString(raw.dueDateActual), + archived: asBoolean(raw.archived), + effortInMinutes: asNumber(raw.effortInMinutes), + progress: asNumber(raw.progress), + atRisk: asBoolean(raw.atRisk), + type: asString(raw.type), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + project: mapTaskProjectRef(raw.project), + phase: mapTaskPhaseRef(raw.phase), + status: mapTaskChoice(raw.status), + priority: mapTaskChoice(raw.priority), + fields: asArray(raw.fields).map(mapTaskField), + assignees: mapTaskAssignees(raw.assignees), + followers: mapTaskFollowers(raw.followers), + dependencies: asArray(raw.dependencies).map(mapTaskLite), + parent: asObject(raw.parent) ? mapTaskLite(raw.parent) : null, + externalReferenceId: asString(raw.externalReferenceId), + billable: asBoolean(raw.billable), + timeEntryCategory: mapTaskTimeEntryCategory(raw.timeEntryCategory), + financialsBudgets: asArray(raw.financialsBudgets).map(mapTaskBudget), + csatEnabled: asBoolean(raw.csatEnabled), + private: asBoolean(raw.private), + } +} + +/** + * Builds the `members` array used by assignee/follower request bodies from + * user IDs and/or email IDs. Each ID becomes its own member entry, so a user + * referenced by both a user ID and an email produces two entries. + */ +export function buildTaskMembers( + userIds?: number[], + emailIds?: string[] +): Array> { + const members: Array> = [] + for (const userId of userIds ?? []) { + members.push({ userId }) + } + for (const emailId of emailIds ?? []) { + members.push({ emailId }) + } + return members +} + +const TASK_LITE_OUTPUT_PROPERTIES = { + taskId: { type: 'number', description: 'Unique identifier of the task', nullable: true }, + taskName: { type: 'string', description: 'Name of the task', nullable: true }, +} satisfies Record + +const TASK_CHOICE_OUTPUT_PROPERTIES = { + value: { type: 'number', description: 'Unique identifier of the choice', nullable: true }, + label: { type: 'string', description: 'Label of the choice', nullable: true }, +} satisfies Record + +export const TASK_OUTPUT_PROPERTIES = { + taskId: { type: 'number', description: 'Unique identifier of the task', nullable: true }, + taskName: { type: 'string', description: 'Name of the task', nullable: true }, + taskDescription: { + type: 'string', + description: 'Description of the task in HTML format', + nullable: true, + }, + taskPrivateNote: { + type: 'string', + description: 'Private note visible only to team members, in HTML format', + nullable: true, + }, + startDate: { + type: 'string', + description: 'Date when the task starts (YYYY-MM-DD)', + nullable: true, + }, + dueDate: { + type: 'string', + description: 'Date when the task is due (YYYY-MM-DD)', + nullable: true, + }, + startDateActual: { + type: 'string', + description: 'Date the task status changed to In Progress (YYYY-MM-DD)', + nullable: true, + }, + dueDateActual: { + type: 'string', + description: 'Date the task status changed to Completed (YYYY-MM-DD)', + nullable: true, + }, + archived: { type: 'boolean', description: 'Whether the task is archived', nullable: true }, + effortInMinutes: { + type: 'number', + description: 'Expected effort to complete the task, in minutes', + nullable: true, + }, + progress: { type: 'number', description: 'Progress of the task (0-100)', nullable: true }, + atRisk: { + type: 'boolean', + description: 'Whether the task is marked as At Risk', + nullable: true, + }, + type: { type: 'string', description: 'Type of the task: TASK or MILESTONE', nullable: true }, + createdAt: { + type: 'number', + description: 'Time the task was created, in epoch millis', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Time the task was last updated, in epoch millis', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'User who created the task', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'User who last updated the task', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + project: { + type: 'object', + description: 'Project associated with the task', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + phase: { + type: 'object', + description: 'Phase associated with the task', + nullable: true, + properties: { + phaseId: { type: 'number', description: 'Unique identifier of the phase', nullable: true }, + phaseName: { type: 'string', description: 'Name of the phase', nullable: true }, + }, + }, + status: { + type: 'object', + description: 'Status of the task (value and label)', + nullable: true, + properties: TASK_CHOICE_OUTPUT_PROPERTIES, + }, + priority: { + type: 'object', + description: 'Priority of the task (value and label)', + nullable: true, + properties: TASK_CHOICE_OUTPUT_PROPERTIES, + }, + fields: { + type: 'array', + description: 'Custom field values set on the task', + items: { + type: 'object', + properties: { + fieldId: { type: 'number', description: 'Unique identifier of the field', nullable: true }, + fieldLabel: { type: 'string', description: 'Label of the field', nullable: true }, + fieldValue: { + type: 'json', + description: 'Value of the field (string, number, or array)', + nullable: true, + }, + fieldValueLabel: { + type: 'string', + description: 'String representation of the field value', + nullable: true, + }, + }, + }, + }, + assignees: { + type: 'object', + description: 'Assignees of the task (members and placeholders)', + nullable: true, + properties: { + members: { + type: 'array', + description: 'Team members and customers assigned to the task', + items: { type: 'object', properties: USER_SUMMARY_OUTPUT_PROPERTIES }, + }, + placeholders: { + type: 'array', + description: 'Placeholders assigned to the task', + items: { + type: 'object', + properties: { + placeholderId: { + type: 'number', + description: 'Unique identifier of the placeholder', + nullable: true, + }, + placeholderName: { + type: 'string', + description: 'Name of the placeholder', + nullable: true, + }, + role: { + type: 'object', + description: 'Role associated with the placeholder', + nullable: true, + properties: { + roleId: { + type: 'number', + description: 'Unique identifier of the role', + nullable: true, + }, + roleName: { type: 'string', description: 'Name of the role', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + followers: { + type: 'object', + description: 'Followers of the task', + nullable: true, + properties: { + members: { + type: 'array', + description: 'Team members and customers following the task', + items: { type: 'object', properties: USER_SUMMARY_OUTPUT_PROPERTIES }, + }, + }, + }, + dependencies: { + type: 'array', + description: 'Tasks this task depends on (finish-to-start dependencies)', + items: { type: 'object', properties: TASK_LITE_OUTPUT_PROPERTIES }, + }, + parent: { + type: 'object', + description: 'Parent task of the task', + nullable: true, + properties: TASK_LITE_OUTPUT_PROPERTIES, + }, + externalReferenceId: { + type: 'string', + description: 'External reference identifier linking the task to an external system', + nullable: true, + }, + billable: { type: 'boolean', description: 'Whether the task is billable', nullable: true }, + timeEntryCategory: { + type: 'object', + description: 'Category in which the task time entries are added', + nullable: true, + properties: { + categoryId: { + type: 'number', + description: 'Unique identifier of the category', + nullable: true, + }, + categoryName: { type: 'string', description: 'Name of the category', nullable: true }, + }, + }, + financialsBudgets: { + type: 'array', + description: 'Financials budgets in which the task time entries are added', + items: { + type: 'object', + properties: { + budgetId: { + type: 'number', + description: 'Unique identifier of the budget', + nullable: true, + }, + budgetName: { type: 'string', description: 'Name of the budget', nullable: true }, + }, + }, + }, + csatEnabled: { + type: 'boolean', + description: 'Whether a CSAT survey is sent on completion (milestone tasks)', + nullable: true, + }, + private: { type: 'boolean', description: 'Whether the task is private', nullable: true }, +} satisfies Record + +export interface RocketlaneCreateTaskParams extends RocketlaneBaseParams { + taskName: string + projectId: number + taskDescription?: string + taskPrivateNote?: string + startDate?: string + dueDate?: string + effortInMinutes?: number + progress?: number + atRisk?: boolean + type?: string + phaseId?: number + statusValue?: number + assigneeUserIds?: number[] + assigneeEmailIds?: string[] + followerUserIds?: number[] + followerEmailIds?: string[] + parentTaskId?: number + externalReferenceId?: string + private?: boolean + includeFields?: string[] + includeAllFields?: boolean +} + +export interface RocketlaneGetTaskParams extends RocketlaneBaseParams { + taskId: number + includeFields?: string[] + includeAllFields?: boolean +} + +export interface RocketlaneUpdateTaskParams extends RocketlaneBaseParams { + taskId: number + taskName?: string + taskDescription?: string + taskPrivateNote?: string + startDate?: string + dueDate?: string + effortInMinutes?: number + progress?: number + atRisk?: boolean + type?: string + statusValue?: number + externalReferenceId?: string + private?: boolean + includeFields?: string[] + includeAllFields?: boolean +} + +export interface RocketlaneDeleteTaskParams extends RocketlaneBaseParams { + taskId: number +} + +export interface RocketlaneListTasksParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string[] + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + projectId?: number + phaseId?: number + taskName?: string + taskNameContains?: string + taskStatus?: string + startDateFrom?: string + startDateTo?: string + dueDateFrom?: string + dueDateTo?: string + includeArchive?: boolean + externalReferenceId?: string +} + +export interface RocketlaneTaskAssigneesParams extends RocketlaneBaseParams { + taskId: number + memberUserIds?: number[] + memberEmailIds?: string[] +} + +export interface RocketlaneTaskFollowersParams extends RocketlaneBaseParams { + taskId: number + memberUserIds?: number[] + memberEmailIds?: string[] +} + +export interface RocketlaneTaskDependenciesParams extends RocketlaneBaseParams { + taskId: number + dependencyTaskIds: number[] +} + +export interface RocketlaneMoveTaskToPhaseParams extends RocketlaneBaseParams { + taskId: number + phaseId: number +} + +export interface RocketlaneTaskResponse extends ToolResponse { + output: { + task: RocketlaneTask + } +} + +export interface RocketlaneTaskListResponse extends ToolResponse { + output: { + tasks: RocketlaneTask[] + pagination: RocketlanePagination + } +} + +export interface RocketlaneTaskDeleteResponse extends ToolResponse { + output: { + deleted: boolean + taskId: number | null + } +} + +// endregion + +// region Projects + +/** Company reference (customer or partner) returned on Rocketlane projects. */ +export interface RocketlaneProjectCompany { + companyId: number | null + companyName: string | null + companyUrl: string | null +} + +export function mapProjectCompany(value: unknown): RocketlaneProjectCompany | null { + const raw = asObject(value) + if (!raw) return null + return { + companyId: asNumber(raw.companyId), + companyName: asString(raw.companyName), + companyUrl: asString(raw.companyUrl), + } +} + +export const PROJECT_COMPANY_OUTPUT_PROPERTIES = { + companyId: { type: 'number', description: 'Unique identifier of the company', nullable: true }, + companyName: { type: 'string', description: 'Name of the company', nullable: true }, + companyUrl: { type: 'string', description: 'Website URL of the company', nullable: true }, +} satisfies Record + +/** Project status value/label pair. */ +export interface RocketlaneProjectStatus { + value: number | null + label: string | null +} + +export function mapProjectStatus(value: unknown): RocketlaneProjectStatus | null { + const raw = asObject(value) + if (!raw) return null + return { + value: asNumber(raw.value), + label: asString(raw.label), + } +} + +export const PROJECT_STATUS_OUTPUT_PROPERTIES = { + value: { type: 'number', description: 'Unique identifier of the status', nullable: true }, + label: { type: 'string', description: 'Name of the status', nullable: true }, +} satisfies Record + +/** Custom project field value returned on Rocketlane projects. */ +export interface RocketlaneProjectField { + fieldId: number | null + fieldLabel: string | null + fieldValue: string | null + fieldValueLabel: string | null +} + +export function mapProjectField(value: unknown): RocketlaneProjectField { + const raw = asObject(value) ?? {} + return { + fieldId: asNumber(raw.fieldId), + fieldLabel: asString(raw.fieldLabel), + fieldValue: asString(raw.fieldValue), + fieldValueLabel: asString(raw.fieldValueLabel), + } +} + +export const PROJECT_FIELD_OUTPUT_PROPERTIES = { + fieldId: { type: 'number', description: 'Unique identifier of the custom field', nullable: true }, + fieldLabel: { type: 'string', description: 'Name of the custom project field', nullable: true }, + fieldValue: { type: 'string', description: 'Value assigned to the field', nullable: true }, + fieldValueLabel: { + type: 'string', + description: 'String representation of the field value', + nullable: true, + }, +} satisfies Record + +/** In-progress phase reference returned on Rocketlane projects. */ +export interface RocketlaneProjectPhase { + phaseId: number | null + phaseName: string | null +} + +export function mapProjectPhase(value: unknown): RocketlaneProjectPhase { + const raw = asObject(value) ?? {} + return { + phaseId: asNumber(raw.phaseId), + phaseName: asString(raw.phaseName), + } +} + +export const PROJECT_PHASE_OUTPUT_PROPERTIES = { + phaseId: { type: 'number', description: 'Unique identifier of the phase', nullable: true }, + phaseName: { type: 'string', description: 'Name of the phase', nullable: true }, +} satisfies Record + +/** Template source imported into a Rocketlane project. */ +export interface RocketlaneProjectSource { + prefix: string | null + startDate: string | null + templateId: number | null + templateName: string | null +} + +export function mapProjectSource(value: unknown): RocketlaneProjectSource { + const raw = asObject(value) ?? {} + return { + prefix: asString(raw.prefix), + startDate: asString(raw.startDate), + templateId: asNumber(raw.templateId), + templateName: asString(raw.templateName), + } +} + +export const PROJECT_SOURCE_OUTPUT_PROPERTIES = { + prefix: { + type: 'string', + description: 'Prefix distinguishing which phase or task corresponds to which template', + nullable: true, + }, + startDate: { + type: 'string', + description: 'Date on which the template goes into effect (YYYY-MM-DD)', + nullable: true, + }, + templateId: { type: 'number', description: 'Unique identifier of the template', nullable: true }, + templateName: { type: 'string', description: 'Name of the template', nullable: true }, +} satisfies Record + +/** Project members, customers, and customer champion. */ +export interface RocketlaneProjectTeamMembers { + members: RocketlaneUserSummary[] + customers: RocketlaneUserSummary[] + customerChampion: RocketlaneUserSummary | null +} + +export function mapProjectTeamMembers(value: unknown): RocketlaneProjectTeamMembers { + const raw = asObject(value) ?? {} + return { + members: asArray(raw.members) + .map(mapUserSummary) + .filter((m): m is RocketlaneUserSummary => m !== null), + customers: asArray(raw.customers) + .map(mapUserSummary) + .filter((m): m is RocketlaneUserSummary => m !== null), + customerChampion: mapUserSummary(raw.customerChampion), + } +} + +export const PROJECT_TEAM_MEMBERS_OUTPUT_PROPERTIES = { + members: { + type: 'array', + description: 'Team members working on the project', + items: { type: 'object', properties: USER_SUMMARY_OUTPUT_PROPERTIES }, + }, + customers: { + type: 'array', + description: 'Customer stakeholders involved in the project', + items: { type: 'object', properties: USER_SUMMARY_OUTPUT_PROPERTIES }, + }, + customerChampion: { + type: 'object', + description: 'Customer champion of the project', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, +} satisfies Record + +/** + * Flattened project financials (contract type plus the per-contract-type fields + * from fixedFeeContract, timeAndMaterialContract, and subscriptionContract). + */ +export interface RocketlaneProjectFinancials { + contractType: string | null + revenueRecognitionType: string | null + fixedFee: number | null + projectBudget: number | null + rateCardId: number | null + rateCardName: string | null + subscriptionFrequency: string | null + subscriptionStartDate: string | null + periodMinutes: number | null + periodBudget: number | null + noOfPeriods: number | null +} + +export function mapProjectFinancials(value: unknown): RocketlaneProjectFinancials | null { + const raw = asObject(value) + if (!raw) return null + const fixedFeeContract = asObject(raw.fixedFeeContract) ?? {} + const timeAndMaterialContract = asObject(raw.timeAndMaterialContract) ?? {} + const rateCard = asObject(timeAndMaterialContract.rateCard) ?? {} + const subscriptionContract = asObject(raw.subscriptionContract) ?? {} + return { + contractType: asString(raw.contractType), + revenueRecognitionType: asString(raw.revenueRecognitionType), + fixedFee: asNumber(fixedFeeContract.fixedFee), + projectBudget: asNumber(timeAndMaterialContract.projectBudget), + rateCardId: asNumber(rateCard.rateCardId), + rateCardName: asString(rateCard.rateCardName), + subscriptionFrequency: asString(subscriptionContract.subscriptionFrequency), + subscriptionStartDate: asString(subscriptionContract.subscriptionStartDate), + periodMinutes: asNumber(subscriptionContract.periodMinutes), + periodBudget: asNumber(subscriptionContract.periodBudget), + noOfPeriods: asNumber(subscriptionContract.noOfPeriods), + } +} + +export const PROJECT_FINANCIALS_OUTPUT_PROPERTIES = { + contractType: { + type: 'string', + description: + 'Contract type for the project financials (FIXED_FEE, TIME_AND_MATERIAL, SUBSCRIPTION, or NON_BILLABLE)', + nullable: true, + }, + revenueRecognitionType: { + type: 'string', + description: 'Method used for revenue recognition', + nullable: true, + }, + fixedFee: { + type: 'number', + description: 'Project fee for Fixed fee contract type projects', + nullable: true, + }, + projectBudget: { + type: 'number', + description: 'Budget allocated for Time & Material contract type projects', + nullable: true, + }, + rateCardId: { type: 'number', description: 'Unique identifier of the rate card', nullable: true }, + rateCardName: { type: 'string', description: 'Name of the rate card', nullable: true }, + subscriptionFrequency: { + type: 'string', + description: + 'Interval at which the subscription renews (MONTHLY, QUARTERLY, HALF_YEARLY, YEARLY)', + nullable: true, + }, + subscriptionStartDate: { + type: 'string', + description: 'Date when the subscription interval begins (YYYY-MM-DD)', + nullable: true, + }, + periodMinutes: { + type: 'number', + description: 'Budgeted minutes for each subscription period', + nullable: true, + }, + periodBudget: { + type: 'number', + description: 'Fixed budget of every subscription period', + nullable: true, + }, + noOfPeriods: { + type: 'number', + description: 'Number of periods in the subscription', + nullable: true, + }, +} satisfies Record + +/** A Rocketlane project as returned by the Projects endpoints. */ +export interface RocketlaneProject { + projectId: number | null + projectName: string | null + startDate: string | null + dueDate: string | null + createdAt: number | null + updatedAt: number | null + owner: RocketlaneUserSummary | null + teamMembers: RocketlaneProjectTeamMembers + status: RocketlaneProjectStatus | null + fields: RocketlaneProjectField[] + customer: RocketlaneProjectCompany | null + partnerCompanies: RocketlaneProjectCompany[] + archived: boolean | null + visibility: string | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + currency: string | null + financials: RocketlaneProjectFinancials | null + startDateActual: string | null + dueDateActual: string | null + annualizedRecurringRevenue: number | null + projectFee: number | null + budgetedHours: number | null + percentageBudgetedHoursConsumed: number | null + percentageBudgetConsumed: number | null + trackedHours: number | null + trackedMinutes: number | null + allocatedHours: number | null + allocatedMinutes: number | null + billableHours: number | null + billableMinutes: number | null + nonBillableHours: number | null + nonBillableMinutes: number | null + remainingHours: number | null + remainingMinutes: number | null + progressPercentage: number | null + currentPhases: RocketlaneProjectPhase[] + autoAllocation: boolean | null + sources: RocketlaneProjectSource[] + plannedDurationInDays: number | null + inferredProgress: string | null + projectAgeInDays: number | null + customersInvited: number | null + customersJoined: number | null + externalReferenceId: string | null +} + +export function mapProject(value: unknown): RocketlaneProject { + const raw = asObject(value) ?? {} + return { + projectId: asNumber(raw.projectId), + projectName: asString(raw.projectName), + startDate: asString(raw.startDate), + dueDate: asString(raw.dueDate), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + owner: mapUserSummary(raw.owner), + teamMembers: mapProjectTeamMembers(raw.teamMembers), + status: mapProjectStatus(raw.status), + fields: asArray(raw.fields).map(mapProjectField), + customer: mapProjectCompany(raw.customer), + partnerCompanies: asArray(raw.partnerCompanies) + .map(mapProjectCompany) + .filter((c): c is RocketlaneProjectCompany => c !== null), + archived: asBoolean(raw.archived), + visibility: asString(raw.visibility), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + currency: asString(raw.currency), + financials: mapProjectFinancials(raw.financials), + startDateActual: asString(raw.startDateActual), + dueDateActual: asString(raw.dueDateActual), + annualizedRecurringRevenue: asNumber(raw.annualizedRecurringRevenue), + projectFee: asNumber(raw.projectFee), + budgetedHours: asNumber(raw.budgetedHours), + percentageBudgetedHoursConsumed: asNumber(raw.percentageBudgetedHoursConsumed), + percentageBudgetConsumed: asNumber(raw.percentageBudgetConsumed), + trackedHours: asNumber(raw.trackedHours), + trackedMinutes: asNumber(raw.trackedMinutes), + allocatedHours: asNumber(raw.allocatedHours), + allocatedMinutes: asNumber(raw.allocatedMinutes), + billableHours: asNumber(raw.billableHours), + billableMinutes: asNumber(raw.billableMinutes), + nonBillableHours: asNumber(raw.nonBillableHours), + nonBillableMinutes: asNumber(raw.nonBillableMinutes), + remainingHours: asNumber(raw.remainingHours), + remainingMinutes: asNumber(raw.remainingMinutes), + progressPercentage: asNumber(raw.progressPercentage), + currentPhases: asArray(raw.currentPhases).map(mapProjectPhase), + autoAllocation: asBoolean(raw.autoAllocation), + sources: asArray(raw.sources).map(mapProjectSource), + plannedDurationInDays: asNumber(raw.plannedDurationInDays), + inferredProgress: asString(raw.inferredProgress), + projectAgeInDays: asNumber(raw.projectAgeInDays), + customersInvited: asNumber(raw.customersInvited), + customersJoined: asNumber(raw.customersJoined), + externalReferenceId: asString(raw.externalReferenceId), + } +} + +export const PROJECT_OUTPUT_PROPERTIES = { + projectId: { type: 'number', description: 'Unique identifier of the project', nullable: true }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + startDate: { + type: 'string', + description: 'Date on which the project execution begins (YYYY-MM-DD)', + nullable: true, + }, + dueDate: { + type: 'string', + description: 'Date on which the project execution is planned to complete (YYYY-MM-DD)', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Time when the project was created (epoch millis)', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Time when the project was last updated (epoch millis)', + nullable: true, + }, + owner: { + type: 'object', + description: 'Project owner', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + teamMembers: { + type: 'object', + description: 'Project members, customers, and customer champion', + properties: PROJECT_TEAM_MEMBERS_OUTPUT_PROPERTIES, + }, + status: { + type: 'object', + description: 'Project status value and label', + nullable: true, + properties: PROJECT_STATUS_OUTPUT_PROPERTIES, + }, + fields: { + type: 'array', + description: 'Custom project field values', + items: { type: 'object', properties: PROJECT_FIELD_OUTPUT_PROPERTIES }, + }, + customer: { + type: 'object', + description: 'Customer company of the project', + nullable: true, + properties: PROJECT_COMPANY_OUTPUT_PROPERTIES, + }, + partnerCompanies: { + type: 'array', + description: 'Partner companies on the project', + items: { type: 'object', properties: PROJECT_COMPANY_OUTPUT_PROPERTIES }, + }, + archived: { type: 'boolean', description: 'Whether the project is archived', nullable: true }, + visibility: { + type: 'string', + description: 'Project visibility (EVERYONE, MEMBERS, or GROUP)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the project', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the project', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + currency: { + type: 'string', + description: 'Currency for the project financials (ISO code)', + nullable: true, + }, + financials: { + type: 'object', + description: 'Project financials (contract type and per-contract-type fields)', + nullable: true, + properties: PROJECT_FINANCIALS_OUTPUT_PROPERTIES, + }, + startDateActual: { + type: 'string', + description: 'Date on which the project status changed to in progress (YYYY-MM-DD)', + nullable: true, + }, + dueDateActual: { + type: 'string', + description: 'Date on which the project status changed to completed (YYYY-MM-DD)', + nullable: true, + }, + annualizedRecurringRevenue: { + type: 'number', + description: 'Recurring revenue of the customer subscriptions for a single calendar year', + nullable: true, + }, + projectFee: { + type: 'number', + description: 'Total fee charged for the project', + nullable: true, + }, + budgetedHours: { + type: 'number', + description: 'Total hours allocated for project execution', + nullable: true, + }, + percentageBudgetedHoursConsumed: { + type: 'number', + description: 'Budgeted hours consumed percentage', + nullable: true, + }, + percentageBudgetConsumed: { + type: 'number', + description: 'Budget consumed percentage', + nullable: true, + }, + trackedHours: { + type: 'number', + description: 'Hours tracked as part of submitted time entries', + nullable: true, + }, + trackedMinutes: { + type: 'number', + description: 'Minutes tracked as part of submitted time entries', + nullable: true, + }, + allocatedHours: { + type: 'number', + description: 'Allocated hours against users or placeholders', + nullable: true, + }, + allocatedMinutes: { + type: 'number', + description: 'Allocated minutes against users or placeholders', + nullable: true, + }, + billableHours: { + type: 'number', + description: 'Hours of time entries tracked as billable', + nullable: true, + }, + billableMinutes: { + type: 'number', + description: 'Minutes of time entries tracked as billable', + nullable: true, + }, + nonBillableHours: { + type: 'number', + description: 'Hours of time entries tracked as non-billable', + nullable: true, + }, + nonBillableMinutes: { + type: 'number', + description: 'Minutes of time entries tracked as non-billable', + nullable: true, + }, + remainingHours: { + type: 'number', + description: 'Hours left to complete the project based on tracked and budgeted hours', + nullable: true, + }, + remainingMinutes: { + type: 'number', + description: 'Minutes left to complete the project (complements remainingHours)', + nullable: true, + }, + progressPercentage: { + type: 'number', + description: 'Progress based on completed tasks vs total tasks', + nullable: true, + }, + currentPhases: { + type: 'array', + description: 'Phases currently marked as in progress', + items: { type: 'object', properties: PROJECT_PHASE_OUTPUT_PROPERTIES }, + }, + autoAllocation: { + type: 'boolean', + description: 'Whether auto allocation is enabled for the project', + nullable: true, + }, + sources: { + type: 'array', + description: 'Project templates imported into the project', + items: { type: 'object', properties: PROJECT_SOURCE_OUTPUT_PROPERTIES }, + }, + plannedDurationInDays: { + type: 'number', + description: 'Difference between startDate and dueDate in days', + nullable: true, + }, + inferredProgress: { + type: 'string', + description: 'Inferred progress (ON_TRACK, AHEAD_OF_TIME, RUNNING_LATE, or NONE)', + nullable: true, + }, + projectAgeInDays: { + type: 'number', + description: 'Age of the project in days based on actual dates', + nullable: true, + }, + customersInvited: { + type: 'number', + description: 'Number of customers invited to the project', + nullable: true, + }, + customersJoined: { + type: 'number', + description: 'Number of customers who joined the project', + nullable: true, + }, + externalReferenceId: { + type: 'string', + description: 'Identifier linking the project to an external system', + nullable: true, + }, +} satisfies Record + +/** A project placeholder as returned by the Get placeholders endpoint. */ +export interface RocketlanePlaceholder { + placeholderId: number | null + placeholderName: string | null + project: RocketlanePlaceholderProjectRef | null + role: RocketlanePlaceholderRole | null + placeholderType: string | null + createdAt: number | null + updatedAt: number | null +} + +/** Project reference attached to a placeholder. */ +export interface RocketlanePlaceholderProjectRef { + projectId: number | null + projectName: string | null +} + +/** Role reference attached to a placeholder. */ +export interface RocketlanePlaceholderRole { + roleId: number | null + roleName: string | null +} + +export function mapPlaceholder(value: unknown): RocketlanePlaceholder { + const raw = asObject(value) ?? {} + const project = asObject(raw.project) + const role = asObject(raw.role) + return { + placeholderId: asNumber(raw.placeholderId), + placeholderName: asString(raw.placeholderName), + project: project + ? { projectId: asNumber(project.projectId), projectName: asString(project.projectName) } + : null, + role: role ? { roleId: asNumber(role.roleId), roleName: asString(role.roleName) } : null, + placeholderType: asString(raw.placeholderType), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + } +} + +export const PLACEHOLDER_OUTPUT_PROPERTIES = { + placeholderId: { + type: 'number', + description: 'Unique identifier of the placeholder', + nullable: true, + }, + placeholderName: { type: 'string', description: 'Name of the placeholder', nullable: true }, + project: { + type: 'object', + description: 'Project of the placeholder', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + role: { + type: 'object', + description: 'Role of the placeholder', + nullable: true, + properties: { + roleId: { type: 'number', description: 'Unique identifier of the role', nullable: true }, + roleName: { type: 'string', description: 'Name of the role', nullable: true }, + }, + }, + placeholderType: { + type: 'string', + description: 'Type of the placeholder (NATIVE or EXTERNAL)', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Time when the placeholder was created (epoch millis)', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Time when the placeholder was last updated (epoch millis)', + nullable: true, + }, +} satisfies Record + +/** User assigned to a placeholder mapping (user summary plus role name). */ +export interface RocketlanePlaceholderMappingUser { + userId: number | null + firstName: string | null + lastName: string | null + emailId: string | null + role: string | null +} + +/** Placeholder-to-user mapping returned by assign/unassign placeholder endpoints. */ +export interface RocketlanePlaceholderMapping { + placeholder: RocketlanePlaceholderRef | null + placeholderStatus: string | null + user: RocketlanePlaceholderMappingUser | null + hourlyCostRate: number | null + costRateCurrency: string | null + hourlyBillRate: number | null + billRateCurrency: string | null +} + +/** Compact placeholder reference inside a placeholder mapping. */ +export interface RocketlanePlaceholderRef { + placeholderId: number | null + placeholderName: string | null +} + +export function mapPlaceholderMapping(value: unknown): RocketlanePlaceholderMapping { + const raw = asObject(value) ?? {} + const placeholder = asObject(raw.placeholder) + const user = asObject(raw.user) + return { + placeholder: placeholder + ? { + placeholderId: asNumber(placeholder.placeholderId), + placeholderName: asString(placeholder.placeholderName), + } + : null, + placeholderStatus: asString(raw.placeholderStatus), + user: user + ? { + userId: asNumber(user.userId), + firstName: asString(user.firstName), + lastName: asString(user.lastName), + emailId: asString(user.emailId), + role: asString(user.role), + } + : null, + hourlyCostRate: asNumber(raw.hourlyCostRate), + costRateCurrency: asString(raw.costRateCurrency), + hourlyBillRate: asNumber(raw.hourlyBillRate), + billRateCurrency: asString(raw.billRateCurrency), + } +} + +export const PLACEHOLDER_MAPPING_OUTPUT_PROPERTIES = { + placeholder: { + type: 'object', + description: 'Placeholder being mapped', + nullable: true, + properties: { + placeholderId: { + type: 'number', + description: 'Unique identifier of the placeholder', + nullable: true, + }, + placeholderName: { type: 'string', description: 'Name of the placeholder', nullable: true }, + }, + }, + placeholderStatus: { + type: 'string', + description: 'Status of the placeholder (ASSIGNED or UNASSIGNED)', + nullable: true, + }, + user: { + type: 'object', + description: 'User assigned to the placeholder', + nullable: true, + properties: { + userId: { type: 'number', description: 'Unique identifier of the user', nullable: true }, + firstName: { type: 'string', description: 'First name of the user', nullable: true }, + lastName: { type: 'string', description: 'Last name of the user', nullable: true }, + emailId: { type: 'string', description: 'Email address of the user', nullable: true }, + role: { type: 'string', description: 'Role name of the assigned user', nullable: true }, + }, + }, + hourlyCostRate: { + type: 'number', + description: 'Latest hourly cost rate for the placeholder', + nullable: true, + }, + costRateCurrency: { + type: 'string', + description: 'Currency for the cost rate', + nullable: true, + }, + hourlyBillRate: { + type: 'number', + description: 'Latest hourly bill rate for the placeholder', + nullable: true, + }, + billRateCurrency: { + type: 'string', + description: 'Currency for the bill rate', + nullable: true, + }, +} satisfies Record + +/** Custom field assignment sent when creating or updating a project. */ +export interface RocketlaneProjectFieldInput { + fieldId: number + fieldValue: string | number | number[] +} + +/** Template source sent when creating a project. */ +export interface RocketlaneProjectSourceInput { + templateId: number + startDate: string + prefix?: string +} + +/** Placeholder-to-user mapping sent when creating a project or assigning placeholders. */ +export interface RocketlaneProjectPlaceholderInput { + placeholderId: number + user: { + userId?: number + emailId?: string + } +} + +export interface RocketlaneCreateProjectParams extends RocketlaneBaseParams { + projectName: string + customerCompanyName: string + ownerUserId?: number + ownerEmailId?: string + startDate?: string + dueDate?: string + visibility?: string + statusValue?: number + memberUserIds?: number[] + customerUserIds?: number[] + customerChampionUserId?: number + fields?: RocketlaneProjectFieldInput[] + sources?: RocketlaneProjectSourceInput[] + placeholders?: RocketlaneProjectPlaceholderInput[] + assignProjectOwner?: boolean + annualizedRecurringRevenue?: number + projectFee?: number + autoAllocation?: boolean + autoCreateCompany?: boolean + budgetedHours?: number + contractType?: string + fixedFee?: number + projectBudget?: number + rateCardId?: number + subscriptionFrequency?: string + subscriptionStartDate?: string + periodMinutes?: number + periodBudget?: number + noOfPeriods?: number + currency?: string + externalReferenceId?: string + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneGetProjectParams extends RocketlaneBaseParams { + projectId: number + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneUpdateProjectParams extends RocketlaneBaseParams { + projectId: number + projectName?: string + startDate?: string + dueDate?: string + visibility?: string + ownerUserId?: number + ownerEmailId?: string + statusValue?: number + fields?: RocketlaneProjectFieldInput[] + annualizedRecurringRevenue?: number + projectFee?: number + autoAllocation?: boolean + budgetedHours?: number + externalReferenceId?: string + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneDeleteProjectParams extends RocketlaneBaseParams { + projectId: number +} + +export interface RocketlaneListProjectsParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + projectNameContains?: string + projectNameEquals?: string + statusEquals?: string + statusOneOf?: string + customerIdEquals?: string + customerIdOneOf?: string + teamMemberIdEquals?: string + contractTypeEquals?: string + includeArchived?: boolean + externalReferenceIdEquals?: string + startDateAfter?: string + startDateBefore?: string + dueDateAfter?: string + dueDateBefore?: string +} + +export interface RocketlaneArchiveProjectParams extends RocketlaneBaseParams { + projectId: number +} + +export interface RocketlaneAddProjectMembersParams extends RocketlaneBaseParams { + projectId: number + memberUserIds?: number[] + memberEmailIds?: string[] + customerUserIds?: number[] + customerEmailIds?: string[] +} + +export interface RocketlaneRemoveProjectMembersParams extends RocketlaneBaseParams { + projectId: number + memberUserIds?: number[] + memberEmailIds?: string[] +} + +export interface RocketlaneImportTemplateParams extends RocketlaneBaseParams { + projectId: number + templateId: number + startDate: string + prefix?: string +} + +export interface RocketlaneAssignPlaceholdersParams extends RocketlaneBaseParams { + projectId: number + placeholderId: number + userId?: number + userEmailId?: string +} + +export interface RocketlaneUnassignPlaceholdersParams extends RocketlaneBaseParams { + projectId: number + placeholderId: number +} + +export interface RocketlaneListPlaceholdersParams extends RocketlaneBaseParams { + projectId: number +} + +export interface RocketlaneProjectResponse extends ToolResponse { + output: { + project: RocketlaneProject + } +} + +export interface RocketlaneProjectListResponse extends ToolResponse { + output: { + projects: RocketlaneProject[] + pagination: RocketlanePagination + } +} + +export interface RocketlaneProjectDeleteResponse extends ToolResponse { + output: { + deleted: boolean + projectId: number | null + } +} + +export interface RocketlaneProjectArchiveResponse extends ToolResponse { + output: { + archived: boolean + projectId: number | null + } +} + +export interface RocketlaneProjectPlaceholdersResponse extends ToolResponse { + output: { + project: RocketlaneProject + placeholders: RocketlanePlaceholderMapping[] + } +} + +export interface RocketlanePlaceholderListResponse extends ToolResponse { + output: { + placeholders: RocketlanePlaceholder[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Fields + +/** Choice option attached to a `SINGLE_CHOICE` or `MULTIPLE_CHOICE` field. */ +export interface RocketlaneFieldOption { + optionValue: number | null + optionLabel: string | null + optionColor: string | null +} + +export function mapFieldOption(value: unknown): RocketlaneFieldOption { + const raw = asObject(value) ?? {} + return { + optionValue: asNumber(raw.optionValue), + optionLabel: asString(raw.optionLabel), + optionColor: asString(raw.optionColor), + } +} + +export const FIELD_OPTION_OUTPUT_PROPERTIES = { + optionValue: { + type: 'number', + description: 'Unique identifier of the option within the field', + nullable: true, + }, + optionLabel: { type: 'string', description: 'Display label of the option', nullable: true }, + optionColor: { + type: 'string', + description: + 'Color of the option (RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY)', + nullable: true, + }, +} satisfies Record + +/** Custom field defined on a Rocketlane account. */ +export interface RocketlaneField { + fieldId: number | null + fieldLabel: string | null + fieldDescription: string | null + fieldType: string | null + objectType: string | null + fieldOptions: RocketlaneFieldOption[] + ratingScale: string | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + createdAt: number | null + updatedAt: number | null + enabled: boolean | null + private: boolean | null +} + +export function mapField(value: unknown): RocketlaneField { + const raw = asObject(value) ?? {} + return { + fieldId: asNumber(raw.fieldId), + fieldLabel: asString(raw.fieldLabel), + fieldDescription: asString(raw.fieldDescription), + fieldType: asString(raw.fieldType), + objectType: asString(raw.objectType), + fieldOptions: asArray(raw.fieldOptions).map(mapFieldOption), + ratingScale: asString(raw.ratingScale), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + enabled: asBoolean(raw.enabled), + private: asBoolean(raw.private), + } +} + +export const FIELD_OUTPUT_PROPERTIES = { + fieldId: { type: 'number', description: 'Unique identifier of the field', nullable: true }, + fieldLabel: { type: 'string', description: 'Name of the field', nullable: true }, + fieldDescription: { type: 'string', description: 'Description of the field', nullable: true }, + fieldType: { + type: 'string', + description: + 'Type of the field (TEXT, MULTI_LINE_TEXT, YES_OR_NO, DATE, SINGLE_CHOICE, MULTIPLE_CHOICE, SINGLE_USER, MULTIPLE_USER, NUMBER, NOTE, RATING)', + nullable: true, + }, + objectType: { + type: 'string', + description: 'Object the field is associated with (PROJECT, TASK, or USER)', + nullable: true, + }, + fieldOptions: { + type: 'array', + description: 'Options available for SINGLE_CHOICE and MULTIPLE_CHOICE fields', + items: { type: 'object', properties: FIELD_OPTION_OUTPUT_PROPERTIES }, + }, + ratingScale: { + type: 'string', + description: 'Rating scale for RATING fields (THREE, FIVE, SEVEN, TEN)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the field', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the field', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + createdAt: { + type: 'number', + description: 'Time the field was created, in epoch milliseconds', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Time the field was last updated, in epoch milliseconds', + nullable: true, + }, + enabled: { type: 'boolean', description: 'Whether the field is enabled', nullable: true }, + private: { type: 'boolean', description: 'Whether the field is private', nullable: true }, +} satisfies Record + +/** Option payload accepted when creating a field. */ +export interface RocketlaneFieldOptionInput { + optionLabel: string + optionColor: string +} + +export interface RocketlaneCreateFieldParams extends RocketlaneBaseParams { + fieldLabel: string + fieldType: string + objectType: string + fieldDescription?: string + fieldOptions?: RocketlaneFieldOptionInput[] + ratingScale?: string + enabled?: boolean + private?: boolean + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneGetFieldParams extends RocketlaneBaseParams { + fieldId: number + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneUpdateFieldParams extends RocketlaneBaseParams { + fieldId: number + fieldLabel?: string + fieldDescription?: string + enabled?: boolean + private?: boolean + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneDeleteFieldParams extends RocketlaneBaseParams { + fieldId: number +} + +export interface RocketlaneListFieldsParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + objectType?: string + fieldType?: string + enabled?: boolean + private?: boolean +} + +export interface RocketlaneAddFieldOptionParams extends RocketlaneBaseParams { + fieldId: number + optionLabel: string + optionColor: string +} + +export interface RocketlaneUpdateFieldOptionParams extends RocketlaneBaseParams { + fieldId: number + optionValue: number + optionLabel?: string + optionColor?: string +} + +export interface RocketlaneFieldResponse extends ToolResponse { + output: { + field: RocketlaneField + } +} + +export interface RocketlaneFieldListResponse extends ToolResponse { + output: { + fields: RocketlaneField[] + pagination: RocketlanePagination + } +} + +export interface RocketlaneFieldDeleteResponse extends ToolResponse { + output: { + deleted: boolean + fieldId: number | null + } +} + +export interface RocketlaneFieldOptionResponse extends ToolResponse { + output: { + option: RocketlaneFieldOption + } +} + +// endregion + +// region Phases + +/** Compact project reference returned inside a phase. */ +export interface RocketlanePhaseProject { + projectId: number | null + projectName: string | null +} + +/** Status of a phase, as a numeric value with a display label. */ +export interface RocketlanePhaseStatus { + value: number | null + label: string | null +} + +/** Phase of a Rocketlane project. */ +export interface RocketlanePhase { + phaseId: number | null + phaseName: string | null + project: RocketlanePhaseProject | null + startDate: string | null + dueDate: string | null + startDateActual: string | null + dueDateActual: string | null + createdAt: number | null + updatedAt: number | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + status: RocketlanePhaseStatus | null + private: boolean | null +} + +export function mapPhase(value: unknown): RocketlanePhase { + const raw = asObject(value) ?? {} + const project = asObject(raw.project) + const status = asObject(raw.status) + return { + phaseId: asNumber(raw.phaseId), + phaseName: asString(raw.phaseName), + project: project + ? { projectId: asNumber(project.projectId), projectName: asString(project.projectName) } + : null, + startDate: asString(raw.startDate), + dueDate: asString(raw.dueDate), + startDateActual: asString(raw.startDateActual), + dueDateActual: asString(raw.dueDateActual), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + status: status ? { value: asNumber(status.value), label: asString(status.label) } : null, + private: asBoolean(raw.private), + } +} + +export const PHASE_OUTPUT_PROPERTIES = { + phaseId: { type: 'number', description: 'Unique identifier of the phase', nullable: true }, + phaseName: { type: 'string', description: 'Name of the phase', nullable: true }, + project: { + type: 'object', + description: 'Project the phase belongs to', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + startDate: { + type: 'string', + description: 'Planned start date of the phase (YYYY-MM-DD)', + nullable: true, + }, + dueDate: { + type: 'string', + description: 'Planned due date of the phase (YYYY-MM-DD)', + nullable: true, + }, + startDateActual: { + type: 'string', + description: 'Actual start date of the phase (YYYY-MM-DD)', + nullable: true, + }, + dueDateActual: { + type: 'string', + description: 'Actual due date of the phase (YYYY-MM-DD)', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Time the phase was created, in epoch milliseconds', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Time the phase was last updated, in epoch milliseconds', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the phase', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the phase', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + status: { + type: 'object', + description: 'Status of the phase', + nullable: true, + properties: { + value: { type: 'number', description: 'Numeric status value', nullable: true }, + label: { type: 'string', description: 'Display label of the status', nullable: true }, + }, + }, + private: { type: 'boolean', description: 'Whether the phase is private', nullable: true }, +} satisfies Record + +export interface RocketlaneCreatePhaseParams extends RocketlaneBaseParams { + phaseName: string + projectId: number + startDate: string + dueDate: string + statusValue?: number + private?: boolean + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneGetPhaseParams extends RocketlaneBaseParams { + phaseId: number + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneUpdatePhaseParams extends RocketlaneBaseParams { + phaseId: number + phaseName?: string + startDate?: string + dueDate?: string + statusValue?: number + private?: boolean + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneDeletePhaseParams extends RocketlaneBaseParams { + phaseId: number +} + +export interface RocketlaneListPhasesParams extends RocketlaneBaseParams { + projectId: number + pageSize?: number + pageToken?: string + includeFields?: string + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + phaseName?: string +} + +export interface RocketlanePhaseResponse extends ToolResponse { + output: { + phase: RocketlanePhase + } +} + +export interface RocketlanePhaseListResponse extends ToolResponse { + output: { + phases: RocketlanePhase[] + pagination: RocketlanePagination + } +} + +export interface RocketlanePhaseDeleteResponse extends ToolResponse { + output: { + deleted: boolean + phaseId: number | null + } +} + +// endregion + +// region Time Entries + +/** Project reference embedded in a time entry. */ +export interface RocketlaneTimeEntryProject { + projectId: number | null + projectName: string | null +} + +/** Task reference embedded in a time entry. */ +export interface RocketlaneTimeEntryTask { + taskId: number | null + taskName: string | null +} + +/** Project phase reference embedded in a time entry. */ +export interface RocketlaneTimeEntryPhase { + phaseId: number | null + phaseName: string | null +} + +/** Category associated with a time entry. */ +export interface RocketlaneTimeEntryCategory { + categoryId: number | null + categoryName: string | null +} + +/** Hourly cost/bill rate attached to a time entry. */ +export interface RocketlaneTimeEntryRate { + rate: number | null + currency: string | null +} + +/** Custom field value attached to a time entry. */ +export interface RocketlaneTimeEntryField { + fieldId: number | null + fieldLabel: string | null + fieldValue: unknown + fieldValueLabel: string | null +} + +/** Normalized Rocketlane time entry returned by the Time Tracking endpoints. */ +export interface RocketlaneTimeEntry { + timeEntryId: number | null + date: string | null + minutes: number | null + activityName: string | null + project: RocketlaneTimeEntryProject | null + task: RocketlaneTimeEntryTask | null + projectPhase: RocketlaneTimeEntryPhase | null + billable: boolean | null + user: RocketlaneUserSummary | null + notes: string | null + category: RocketlaneTimeEntryCategory | null + sourceType: string | null + status: string | null + createdAt: number | null + updatedAt: number | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + submittedBy: RocketlaneUserSummary | null + submittedAt: number | null + approvedBy: RocketlaneUserSummary | null + approvedAt: number | null + rejectedBy: RocketlaneUserSummary | null + rejectedAt: number | null + deleted: boolean | null + costRate: RocketlaneTimeEntryRate | null + billRate: RocketlaneTimeEntryRate | null + fields: RocketlaneTimeEntryField[] +} + +function mapTimeEntryProject(value: unknown): RocketlaneTimeEntryProject | null { + const raw = asObject(value) + if (!raw) return null + return { + projectId: asNumber(raw.projectId), + projectName: asString(raw.projectName), + } +} + +function mapTimeEntryTask(value: unknown): RocketlaneTimeEntryTask | null { + const raw = asObject(value) + if (!raw) return null + return { + taskId: asNumber(raw.taskId), + taskName: asString(raw.taskName), + } +} + +function mapTimeEntryPhase(value: unknown): RocketlaneTimeEntryPhase | null { + const raw = asObject(value) + if (!raw) return null + return { + phaseId: asNumber(raw.phaseId), + phaseName: asString(raw.phaseName), + } +} + +export function mapTimeEntryCategory(value: unknown): RocketlaneTimeEntryCategory | null { + const raw = asObject(value) + if (!raw) return null + return { + categoryId: asNumber(raw.categoryId), + categoryName: asString(raw.categoryName), + } +} + +function mapTimeEntryRate(value: unknown): RocketlaneTimeEntryRate | null { + const raw = asObject(value) + if (!raw) return null + return { + rate: asNumber(raw.rate), + currency: asString(raw.currency), + } +} + +function mapTimeEntryField(value: unknown): RocketlaneTimeEntryField { + const raw = asObject(value) ?? {} + return { + fieldId: asNumber(raw.fieldId), + fieldLabel: asString(raw.fieldLabel), + fieldValue: raw.fieldValue ?? null, + fieldValueLabel: asString(raw.fieldValueLabel), + } +} + +export function mapTimeEntry(value: unknown): RocketlaneTimeEntry { + const raw = asObject(value) ?? {} + return { + timeEntryId: asNumber(raw.timeEntryId), + date: asString(raw.date), + minutes: asNumber(raw.minutes), + activityName: asString(raw.activityName), + project: mapTimeEntryProject(raw.project), + task: mapTimeEntryTask(raw.task), + projectPhase: mapTimeEntryPhase(raw.projectPhase), + billable: asBoolean(raw.billable), + user: mapUserSummary(raw.user), + notes: asString(raw.notes), + category: mapTimeEntryCategory(raw.category), + sourceType: asString(raw.sourceType), + status: asString(raw.status), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + submittedBy: mapUserSummary(raw.submittedBy), + submittedAt: asNumber(raw.submittedAt), + approvedBy: mapUserSummary(raw.approvedBy), + approvedAt: asNumber(raw.approvedAt), + rejectedBy: mapUserSummary(raw.rejectedBy), + rejectedAt: asNumber(raw.rejectedAt), + deleted: asBoolean(raw.deleted), + costRate: mapTimeEntryRate(raw.costRate), + billRate: mapTimeEntryRate(raw.billRate), + fields: asArray(raw.fields).map(mapTimeEntryField), + } +} + +export const TIME_ENTRY_CATEGORY_OUTPUT_PROPERTIES = { + categoryId: { type: 'number', description: 'Unique identifier of the category', nullable: true }, + categoryName: { type: 'string', description: 'Name of the category', nullable: true }, +} satisfies Record + +const TIME_ENTRY_RATE_OUTPUT_PROPERTIES = { + rate: { type: 'number', description: 'Hourly monetary rate', nullable: true }, + currency: { type: 'string', description: 'Three-letter ISO currency code', nullable: true }, +} satisfies Record + +export const TIME_ENTRY_OUTPUT_PROPERTIES = { + timeEntryId: { + type: 'number', + description: 'Unique identifier of the time entry', + nullable: true, + }, + date: { + type: 'string', + description: 'Date of the time entry (YYYY-MM-DD)', + nullable: true, + }, + minutes: { + type: 'number', + description: 'Duration of the time entry in minutes', + nullable: true, + }, + activityName: { + type: 'string', + description: 'Name of the adhoc activity, when the entry is tracked against an activity', + nullable: true, + }, + project: { + type: 'object', + description: 'Project associated with the time entry', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + task: { + type: 'object', + description: 'Task associated with the time entry', + nullable: true, + properties: { + taskId: { type: 'number', description: 'Unique identifier of the task', nullable: true }, + taskName: { type: 'string', description: 'Name of the task', nullable: true }, + }, + }, + projectPhase: { + type: 'object', + description: 'Project phase associated with the time entry', + nullable: true, + properties: { + phaseId: { type: 'number', description: 'Unique identifier of the phase', nullable: true }, + phaseName: { type: 'string', description: 'Name of the phase', nullable: true }, + }, + }, + billable: { + type: 'boolean', + description: 'Whether the time entry is billable', + nullable: true, + }, + user: { + type: 'object', + description: 'User the time entry belongs to', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + notes: { + type: 'string', + description: 'Notes for the time entry', + nullable: true, + }, + category: { + type: 'object', + description: 'Category associated with the time entry', + nullable: true, + properties: TIME_ENTRY_CATEGORY_OUTPUT_PROPERTIES, + }, + sourceType: { + type: 'string', + description: + 'Source of the time entry (GOOGLE_CALENDAR, OUTLOOK_CALENDAR, TASK, PROJECT, PHASE, ADHOC, MILESTONE)', + nullable: true, + }, + status: { + type: 'string', + description: 'Approval status of the time entry (NOT_SUBMITTED, SUBMITTED, APPROVED, REJECTED)', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Creation timestamp in epoch milliseconds', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Last-updated timestamp in epoch milliseconds', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'User who created the time entry', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'User who last updated the time entry', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + submittedBy: { + type: 'object', + description: + 'User who submitted the time entry (may be null even for approved/rejected entries)', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + submittedAt: { + type: 'number', + description: 'Submission timestamp in epoch milliseconds', + nullable: true, + }, + approvedBy: { + type: 'object', + description: 'User who approved the time entry', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + approvedAt: { + type: 'number', + description: 'Approval timestamp in epoch milliseconds', + nullable: true, + }, + rejectedBy: { + type: 'object', + description: 'User who rejected the time entry', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + rejectedAt: { + type: 'number', + description: 'Rejection timestamp in epoch milliseconds', + nullable: true, + }, + deleted: { + type: 'boolean', + description: 'Whether the time entry is deleted', + nullable: true, + }, + costRate: { + type: 'object', + description: 'Hourly cost rate assigned to the user for this entry', + nullable: true, + properties: TIME_ENTRY_RATE_OUTPUT_PROPERTIES, + }, + billRate: { + type: 'object', + description: 'Hourly rate billed to the customer for this entry', + nullable: true, + properties: TIME_ENTRY_RATE_OUTPUT_PROPERTIES, + }, + fields: { + type: 'array', + description: 'Custom fields associated with the time entry', + items: { + type: 'object', + properties: { + fieldId: { type: 'number', description: 'Unique identifier of the field', nullable: true }, + fieldLabel: { type: 'string', description: 'Label of the field', nullable: true }, + fieldValue: { type: 'json', description: 'Value of the field', nullable: true }, + fieldValueLabel: { + type: 'string', + description: 'String representation of the field value', + nullable: true, + }, + }, + }, + }, +} satisfies Record + +/** Params for `rocketlane_create_time_entry`. */ +export interface RocketlaneCreateTimeEntryParams extends RocketlaneBaseParams { + date: string + minutes: number + activityName?: string + taskId?: number + projectPhaseId?: number + projectId?: number + billable?: boolean + userId?: number + userEmail?: string + notes?: string + categoryId?: number + includeFields?: string + includeAllFields?: boolean +} + +/** Params for `rocketlane_get_time_entry`. */ +export interface RocketlaneGetTimeEntryParams extends RocketlaneBaseParams { + timeEntryId: number + includeFields?: string + includeAllFields?: boolean +} + +/** Params for `rocketlane_update_time_entry`. */ +export interface RocketlaneUpdateTimeEntryParams extends RocketlaneBaseParams { + timeEntryId: number + date: string + minutes: number + activityName?: string + notes?: string + billable?: boolean + categoryId?: number + includeFields?: string + includeAllFields?: boolean +} + +/** Params for `rocketlane_delete_time_entry`. */ +export interface RocketlaneDeleteTimeEntryParams extends RocketlaneBaseParams { + timeEntryId: number +} + +/** Filter/sort/pagination params shared by the list and search time entry tools. */ +export interface RocketlaneTimeEntryFilterParams extends RocketlaneBaseParams { + sortBy?: string + sortOrder?: string + match?: string + dateEq?: string + dateGt?: string + dateGe?: string + dateLt?: string + dateLe?: string + projectPhaseIdEq?: number + categoryIdEq?: number + userIdEq?: number + sourceTypeEq?: string + activityNameEq?: string + activityNameCn?: string + approvalStatusEq?: string + pageSize?: number + pageToken?: string +} + +/** Params for `rocketlane_list_time_entries`. */ +export interface RocketlaneListTimeEntriesParams extends RocketlaneTimeEntryFilterParams { + projectIdEq?: number + taskIdEq?: number + emailIdEq?: string + emailIdCn?: string + billableEq?: boolean + includeDeletedEq?: boolean + submittedByEq?: number + approvedByEq?: number + rejectedByEq?: number + createdAtGt?: number + createdAtLt?: number + updatedAtGt?: number + updatedAtLt?: number + includeFields?: string +} + +/** Params for `rocketlane_search_time_entries` (deprecated search endpoint). */ +export interface RocketlaneSearchTimeEntriesParams extends RocketlaneTimeEntryFilterParams { + projectEq?: number + taskEq?: number + includeFields?: string + includeAllFields?: boolean +} + +/** Params for `rocketlane_list_time_entry_categories`. */ +export interface RocketlaneListTimeEntryCategoriesParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string +} + +/** Response carrying a single time entry. */ +export interface RocketlaneTimeEntryResponse extends ToolResponse { + output: { + timeEntry: RocketlaneTimeEntry + } +} + +/** Response for the delete time entry tool. */ +export interface RocketlaneDeleteTimeEntryResponse extends ToolResponse { + output: { + deleted: boolean + timeEntryId: number | null + } +} + +/** Response carrying a paginated list of time entries. */ +export interface RocketlaneTimeEntryListResponse extends ToolResponse { + output: { + timeEntries: RocketlaneTimeEntry[] + pagination: RocketlanePagination + } +} + +/** Response carrying a paginated list of time entry categories. */ +export interface RocketlaneTimeEntryCategoryListResponse extends ToolResponse { + output: { + categories: RocketlaneTimeEntryCategory[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Spaces + +/** The project a space belongs to. */ +export interface RocketlaneSpaceProject { + projectId: number | null + projectName: string | null +} + +/** A Rocketlane space. */ +export interface RocketlaneSpace { + spaceId: number | null + spaceName: string | null + project: RocketlaneSpaceProject | null + createdAt: number | null + createdBy: RocketlaneUserSummary | null + updatedAt: number | null + updatedBy: RocketlaneUserSummary | null + private: boolean | null +} + +export function mapSpace(value: unknown): RocketlaneSpace { + const raw = asObject(value) ?? {} + const project = asObject(raw.project) + return { + spaceId: asNumber(raw.spaceId), + spaceName: asString(raw.spaceName), + project: project + ? { + projectId: asNumber(project.projectId), + projectName: asString(project.projectName), + } + : null, + createdAt: asNumber(raw.createdAt), + createdBy: mapUserSummary(raw.createdBy), + updatedAt: asNumber(raw.updatedAt), + updatedBy: mapUserSummary(raw.updatedBy), + private: asBoolean(raw.private), + } +} + +export const SPACE_OUTPUT_PROPERTIES = { + spaceId: { type: 'number', description: 'Unique identifier of the space', nullable: true }, + spaceName: { type: 'string', description: 'Name of the space', nullable: true }, + project: { + type: 'object', + description: 'Project the space belongs to', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the space was created (epoch millis)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the space', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedAt: { + type: 'number', + description: 'Timestamp when the space was last updated (epoch millis)', + nullable: true, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the space', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + private: { + type: 'boolean', + description: 'Whether the space is private or shared', + nullable: true, + }, +} satisfies Record + +export interface RocketlaneCreateSpaceParams extends RocketlaneBaseParams { + projectId: number + spaceName: string + private?: boolean +} + +export interface RocketlaneGetSpaceParams extends RocketlaneBaseParams { + spaceId: number +} + +export interface RocketlaneUpdateSpaceParams extends RocketlaneBaseParams { + spaceId: number + spaceName?: string +} + +export interface RocketlaneDeleteSpaceParams extends RocketlaneBaseParams { + spaceId: number +} + +export interface RocketlaneListSpacesParams extends RocketlaneBaseParams { + projectId: number + pageSize?: number + pageToken?: string + sortBy?: string + sortOrder?: string + match?: string + spaceNameEq?: string + spaceNameCn?: string + spaceNameNc?: string + createdAtGt?: number + createdAtEq?: number + createdAtLt?: number + createdAtGe?: number + createdAtLe?: number + updatedAtGt?: number + updatedAtEq?: number + updatedAtLt?: number + updatedAtGe?: number + updatedAtLe?: number +} + +export interface RocketlaneSpaceResponse extends ToolResponse { + output: { + space: RocketlaneSpace + } +} + +export interface RocketlaneDeleteSpaceResponse extends ToolResponse { + output: { + deleted: boolean + spaceId: number | null + } +} + +export interface RocketlaneListSpacesResponse extends ToolResponse { + output: { + spaces: RocketlaneSpace[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Space Documents + +/** The space a space document belongs to. */ +export interface RocketlaneSpaceDocumentSpaceRef { + spaceId: number | null + spaceName: string | null +} + +/** The document template a space document was created from. */ +export interface RocketlaneSpaceDocumentSource { + templateId: number | null + templateName: string | null +} + +/** A Rocketlane space document (space tab). */ +export interface RocketlaneSpaceDocument { + spaceDocumentId: number | null + spaceDocumentName: string | null + space: RocketlaneSpaceDocumentSpaceRef | null + spaceDocumentType: string | null + url: string | null + source: RocketlaneSpaceDocumentSource | null + createdAt: number | null + createdBy: RocketlaneUserSummary | null + updatedAt: number | null + updatedBy: RocketlaneUserSummary | null + private: boolean | null +} + +export function mapSpaceDocument(value: unknown): RocketlaneSpaceDocument { + const raw = asObject(value) ?? {} + const space = asObject(raw.space) + const source = asObject(raw.source) + return { + spaceDocumentId: asNumber(raw.spaceDocumentId), + spaceDocumentName: asString(raw.spaceDocumentName), + space: space + ? { + spaceId: asNumber(space.spaceId), + spaceName: asString(space.spaceName), + } + : null, + spaceDocumentType: asString(raw.spaceDocumentType), + url: asString(raw.url), + source: source + ? { + templateId: asNumber(source.templateId), + templateName: asString(source.templateName), + } + : null, + createdAt: asNumber(raw.createdAt), + createdBy: mapUserSummary(raw.createdBy), + updatedAt: asNumber(raw.updatedAt), + updatedBy: mapUserSummary(raw.updatedBy), + private: asBoolean(raw.private), + } +} + +export const SPACE_DOCUMENT_OUTPUT_PROPERTIES = { + spaceDocumentId: { + type: 'number', + description: 'Unique identifier of the space document', + nullable: true, + }, + spaceDocumentName: { + type: 'string', + description: 'Name of the space document', + nullable: true, + }, + space: { + type: 'object', + description: 'Space the document belongs to', + nullable: true, + properties: { + spaceId: { type: 'number', description: 'Unique identifier of the space', nullable: true }, + spaceName: { type: 'string', description: 'Name of the space', nullable: true }, + }, + }, + spaceDocumentType: { + type: 'string', + description: 'Type of the space document (ROCKETLANE_DOCUMENT or EMBEDDED_DOCUMENT)', + nullable: true, + }, + url: { + type: 'string', + description: 'URL embedded in the space document', + nullable: true, + }, + source: { + type: 'object', + description: 'Document template the space document was created from', + nullable: true, + properties: { + templateId: { + type: 'number', + description: 'Unique identifier of the template', + nullable: true, + }, + templateName: { type: 'string', description: 'Name of the template', nullable: true }, + }, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the space document was created (epoch millis)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the space document', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedAt: { + type: 'number', + description: 'Timestamp when the space document was last updated (epoch millis)', + nullable: true, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the space document', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + private: { + type: 'boolean', + description: 'Whether the space document is private or shared', + nullable: true, + }, +} satisfies Record + +export interface RocketlaneCreateSpaceDocumentParams extends RocketlaneBaseParams { + spaceId: number + spaceDocumentType: string + spaceDocumentName?: string + url?: string + templateId?: number +} + +export interface RocketlaneGetSpaceDocumentParams extends RocketlaneBaseParams { + spaceDocumentId: number +} + +export interface RocketlaneUpdateSpaceDocumentParams extends RocketlaneBaseParams { + spaceDocumentId: number + spaceDocumentName?: string + url?: string +} + +export interface RocketlaneDeleteSpaceDocumentParams extends RocketlaneBaseParams { + spaceDocumentId: number +} + +export interface RocketlaneListSpaceDocumentsParams extends RocketlaneBaseParams { + projectId: number + pageSize?: number + pageToken?: string + sortBy?: string + sortOrder?: string + match?: string + spaceDocumentNameEq?: string + spaceDocumentNameCn?: string + spaceDocumentNameNc?: string + spaceIdEq?: number + createdAtGt?: number + createdAtEq?: number + createdAtLt?: number + createdAtGe?: number + createdAtLe?: number + updatedAtGt?: number + updatedAtEq?: number + updatedAtLt?: number + updatedAtGe?: number + updatedAtLe?: number +} + +export interface RocketlaneSpaceDocumentResponse extends ToolResponse { + output: { + spaceDocument: RocketlaneSpaceDocument + } +} + +export interface RocketlaneDeleteSpaceDocumentResponse extends ToolResponse { + output: { + deleted: boolean + spaceDocumentId: number | null + } +} + +export interface RocketlaneListSpaceDocumentsResponse extends ToolResponse { + output: { + spaceDocuments: RocketlaneSpaceDocument[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Users + +/** The role assigned to a user. */ +export interface RocketlaneUserRole { + roleId: number | null + roleName: string | null +} + +/** The company a user belongs to. */ +export interface RocketlaneUserCompany { + companyId: number | null + companyName: string | null +} + +/** The permission level of a user. */ +export interface RocketlaneUserPermission { + permissionId: number | null + permissionName: string | null +} + +/** The holiday calendar assigned to a user. Field names use the API's `calender` spelling. */ +export interface RocketlaneUserHolidayCalendar { + calenderId: number | null + calenderName: string | null +} + +/** A custom user field value. */ +export interface RocketlaneUserField { + fieldId: number | null + fieldLabel: string | null + fieldValue: string | null + fieldValueLabel: string | null +} + +/** A full Rocketlane user (distinct from the compact RocketlaneUserSummary reference). */ +export interface RocketlaneUser { + userId: number | null + email: string | null + firstName: string | null + lastName: string | null + type: string | null + status: string | null + role: RocketlaneUserRole | null + company: RocketlaneUserCompany | null + permission: RocketlaneUserPermission | null + fields: RocketlaneUserField[] + capacityInMinutes: number | null + holidayCalendar: RocketlaneUserHolidayCalendar | null + profilePictureUrl: string | null + createdAt: number | null + createdBy: RocketlaneUserSummary | null + updatedAt: number | null + updatedBy: RocketlaneUserSummary | null +} + +export function mapUser(value: unknown): RocketlaneUser { + const raw = asObject(value) ?? {} + const role = asObject(raw.role) + const company = asObject(raw.company) + const permission = asObject(raw.permission) + const holidayCalendar = asObject(raw.holidayCalendar) + return { + userId: asNumber(raw.userId), + email: asString(raw.email), + firstName: asString(raw.firstName), + lastName: asString(raw.lastName), + type: asString(raw.type), + status: asString(raw.status), + role: role + ? { + roleId: asNumber(role.roleId), + roleName: asString(role.roleName), + } + : null, + company: company + ? { + companyId: asNumber(company.companyId), + companyName: asString(company.companyName), + } + : null, + permission: permission + ? { + permissionId: asNumber(permission.permissionId), + permissionName: asString(permission.permissionName), + } + : null, + fields: asArray(raw.fields).map((field) => { + const fieldRaw = asObject(field) ?? {} + return { + fieldId: asNumber(fieldRaw.fieldId), + fieldLabel: asString(fieldRaw.fieldLabel), + fieldValue: asString(fieldRaw.fieldValue), + fieldValueLabel: asString(fieldRaw.fieldValueLabel), + } + }), + capacityInMinutes: asNumber(raw.capacityInMinutes), + holidayCalendar: holidayCalendar + ? { + calenderId: asNumber(holidayCalendar.calenderId), + calenderName: asString(holidayCalendar.calenderName), + } + : null, + profilePictureUrl: asString(raw.profilePictureUrl), + createdAt: asNumber(raw.createdAt), + createdBy: mapUserSummary(raw.createdBy), + updatedAt: asNumber(raw.updatedAt), + updatedBy: mapUserSummary(raw.updatedBy), + } +} + +export const USER_OUTPUT_PROPERTIES = { + userId: { type: 'number', description: 'Unique identifier of the user', nullable: true }, + email: { type: 'string', description: 'Email address of the user', nullable: true }, + firstName: { type: 'string', description: 'First name of the user', nullable: true }, + lastName: { type: 'string', description: 'Last name of the user', nullable: true }, + type: { + type: 'string', + description: 'Type of the user (TEAM_MEMBER, PARTNER, CUSTOMER, or EXTERNAL_PARTNER)', + nullable: true, + }, + status: { + type: 'string', + description: 'Status of the user (INACTIVE, INVITED, ACTIVE, or PASSIVE)', + nullable: true, + }, + role: { + type: 'object', + description: 'Role of the user', + nullable: true, + properties: { + roleId: { type: 'number', description: 'Unique identifier of the role', nullable: true }, + roleName: { type: 'string', description: 'Name of the role', nullable: true }, + }, + }, + company: { + type: 'object', + description: 'Company of the user', + nullable: true, + properties: { + companyId: { + type: 'number', + description: 'Unique identifier of the company', + nullable: true, + }, + companyName: { type: 'string', description: 'Name of the company', nullable: true }, + }, + }, + permission: { + type: 'object', + description: 'Permission of the user', + nullable: true, + properties: { + permissionId: { + type: 'number', + description: 'Unique identifier of the permission', + nullable: true, + }, + permissionName: { type: 'string', description: 'Name of the permission', nullable: true }, + }, + }, + fields: { + type: 'array', + description: 'Custom user field values', + items: { + type: 'object', + properties: { + fieldId: { type: 'number', description: 'Unique identifier of the field', nullable: true }, + fieldLabel: { + type: 'string', + description: 'Name of the custom user field', + nullable: true, + }, + fieldValue: { + type: 'string', + description: 'Value of the custom user field', + nullable: true, + }, + fieldValueLabel: { + type: 'string', + description: 'String representation of the field value', + nullable: true, + }, + }, + }, + }, + capacityInMinutes: { + type: 'number', + description: 'Capacity of the user in minutes', + nullable: true, + }, + holidayCalendar: { + type: 'object', + description: 'Holiday calendar of the user', + nullable: true, + properties: { + calenderId: { + type: 'number', + description: 'Unique identifier of the holiday calendar', + nullable: true, + }, + calenderName: { + type: 'string', + description: 'Name of the holiday calendar', + nullable: true, + }, + }, + }, + profilePictureUrl: { + type: 'string', + description: "URL of the user's profile picture", + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the user was created (epoch millis)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'Team member who created the user', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedAt: { + type: 'number', + description: 'Timestamp when the user was last updated (epoch millis)', + nullable: true, + }, + updatedBy: { + type: 'object', + description: 'Team member who last updated the user', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, +} satisfies Record + +export interface RocketlaneGetUserParams extends RocketlaneBaseParams { + userId: number + includeFields?: string + includeAllFields?: boolean +} + +export interface RocketlaneListUsersParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + firstNameEq?: string + firstNameCn?: string + firstNameNc?: string + lastNameEq?: string + lastNameCn?: string + lastNameNc?: string + emailEq?: string + emailCn?: string + emailNc?: string + statusEq?: string + statusOneOf?: string + statusNoneOf?: string + typeEq?: string + typeOneOf?: string + roleIdEq?: string + roleIdOneOf?: string + roleIdNoneOf?: string + permissionIdEq?: string + permissionIdOneOf?: string + permissionIdNoneOf?: string + capacityInMinutesEq?: number + capacityInMinutesGt?: number + capacityInMinutesGe?: number + capacityInMinutesLt?: number + capacityInMinutesLe?: number + createdAtGt?: number + createdAtEq?: number + createdAtLt?: number + createdAtGe?: number + createdAtLe?: number + updatedAtGt?: number + updatedAtEq?: number + updatedAtLt?: number + updatedAtGe?: number + updatedAtLe?: number +} + +export interface RocketlaneUserResponse extends ToolResponse { + output: { + user: RocketlaneUser + } +} + +export interface RocketlaneListUsersResponse extends ToolResponse { + output: { + users: RocketlaneUser[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Time-Offs + +/** Params for creating a time-off. The API requires identifying the user by `userId` or `userEmail`. */ +export interface RocketlaneTimeOffCreateParams extends RocketlaneBaseParams { + userId?: number + userEmail?: string + startDate: string + endDate: string + type: string + durationInMinutes?: number + note?: string + notifyProjectOwners?: boolean + notifyUserIds?: number[] + notifyUserEmails?: string[] + includeFields?: string[] + includeAllFields?: boolean +} + +/** Params for fetching a single time-off by ID. */ +export interface RocketlaneTimeOffGetParams extends RocketlaneBaseParams { + timeOffId: number + includeFields?: string[] + includeAllFields?: boolean +} + +/** Params for deleting a time-off by ID. */ +export interface RocketlaneTimeOffDeleteParams extends RocketlaneBaseParams { + timeOffId: number +} + +/** Params for listing time-offs with filters, sorting, and pagination. */ +export interface RocketlaneTimeOffListParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string[] + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + startDateGt?: string + startDateEq?: string + startDateLt?: string + startDateGe?: string + startDateLe?: string + endDateGt?: string + endDateEq?: string + endDateLt?: string + endDateGe?: string + endDateLe?: string + typeEq?: string + typeOneOf?: string + typeNoneOf?: string + userIdEq?: string + userIdOneOf?: string + userIdNoneOf?: string + emailIdEq?: string + emailIdOneOf?: string + emailIdNoneOf?: string +} + +/** The notify-users preferences attached to a time-off. */ +export interface RocketlaneTimeOffNotifyUsers { + projectOwners: boolean | null + others: RocketlaneUserSummary[] +} + +/** A Rocketlane time-off entry. */ +export interface RocketlaneTimeOff { + timeOffId: number | null + user: RocketlaneUserSummary | null + note: string | null + startDate: string | null + endDate: string | null + durationInMinutes: number | null + type: string | null + notifyUsers: RocketlaneTimeOffNotifyUsers | null + createdAt: number | null + createdBy: RocketlaneUserSummary | null +} + +function mapTimeOffNotifyUsers(value: unknown): RocketlaneTimeOffNotifyUsers | null { + const raw = asObject(value) + if (!raw) return null + return { + projectOwners: asBoolean(raw.projectOwners), + others: asArray(raw.others) + .map(mapUserSummary) + .filter((user): user is RocketlaneUserSummary => user !== null), + } +} + +/** + * Maps a raw time-off payload to the normalized {@link RocketlaneTimeOff} shape. + */ +export function mapTimeOff(value: unknown): RocketlaneTimeOff { + const raw = asObject(value) ?? {} + return { + timeOffId: asNumber(raw.timeOffId), + user: mapUserSummary(raw.user), + note: asString(raw.note), + startDate: asString(raw.startDate), + endDate: asString(raw.endDate), + durationInMinutes: asNumber(raw.durationInMinutes), + type: asString(raw.type), + notifyUsers: mapTimeOffNotifyUsers(raw.notifyUsers), + createdAt: asNumber(raw.createdAt), + createdBy: mapUserSummary(raw.createdBy), + } +} + +export const TIME_OFF_OUTPUT_PROPERTIES = { + timeOffId: { type: 'number', description: 'Unique identifier of the time-off', nullable: true }, + user: { + type: 'object', + description: 'The team member the time-off belongs to', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + note: { type: 'string', description: 'Note or comment about the time-off', nullable: true }, + startDate: { + type: 'string', + description: 'Time-off start date (YYYY-MM-DD)', + nullable: true, + }, + endDate: { type: 'string', description: 'Time-off end date (YYYY-MM-DD)', nullable: true }, + durationInMinutes: { + type: 'number', + description: 'Duration in minutes per day for the time-off interval', + nullable: true, + }, + type: { + type: 'string', + description: 'Type of the time-off (FULL_DAY, HALF_DAY, or CUSTOM)', + nullable: true, + }, + notifyUsers: { + type: 'object', + description: 'Users notified about the time-off', + nullable: true, + properties: { + projectOwners: { + type: 'boolean', + description: 'Whether project owners of projects the user is part of are notified', + nullable: true, + }, + others: { + type: 'array', + description: 'Other users notified about the time-off', + items: { type: 'object', properties: USER_SUMMARY_OUTPUT_PROPERTIES }, + }, + }, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the time-off was created (epoch milliseconds)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'The team member who created the time-off', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, +} satisfies Record + +/** Response containing a single time-off. */ +export interface RocketlaneTimeOffResponse extends ToolResponse { + output: { + timeOff: RocketlaneTimeOff + } +} + +/** Response for a time-off deletion. */ +export interface RocketlaneTimeOffDeleteResponse extends ToolResponse { + output: { + deleted: boolean + timeOffId: number | null + } +} + +/** Response containing a page of time-offs. */ +export interface RocketlaneTimeOffListResponse extends ToolResponse { + output: { + timeOffs: RocketlaneTimeOff[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Resource Allocations + +/** Params for listing resource allocations. `startDate` and `endDate` are required by the API. */ +export interface RocketlaneResourceAllocationListParams extends RocketlaneBaseParams { + startDate: string + endDate: string + pageSize?: number + pageToken?: string + includeFields?: string[] + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + memberIdEq?: string + memberIdOneOf?: string + memberIdNoneOf?: string + projectIdEq?: string + projectIdOneOf?: string + projectIdNoneOf?: string + placeholderIdEq?: string + placeholderIdOneOf?: string + placeholderIdNoneOf?: string +} + +/** A role attached to an allocation member or placeholder. */ +export interface RocketlaneResourceAllocationRole { + roleId: number | null + roleName: string | null +} + +/** The team member an allocation is made for, including their role. */ +export interface RocketlaneResourceAllocationMember extends RocketlaneUserSummary { + role: RocketlaneResourceAllocationRole | null +} + +/** The placeholder an allocation is made for. */ +export interface RocketlaneResourceAllocationPlaceholder { + placeholderId: number | null + placeholderName: string | null + role: RocketlaneResourceAllocationRole | null +} + +/** The project associated with an allocation. */ +export interface RocketlaneResourceAllocationProject { + projectId: number | null + projectName: string | null +} + +/** A task associated with an allocation. */ +export interface RocketlaneResourceAllocationTask { + taskId: number | null + taskName: string | null +} + +/** Total duration figures for an allocation between its start and end dates. */ +export interface RocketlaneResourceAllocationDuration { + daysConsider: number | null + seconds: number | null + minutes: number | null + hours: number | null +} + +/** A Rocketlane resource allocation. The API exposes no allocation identifier. */ +export interface RocketlaneResourceAllocation { + startDate: string | null + endDate: string | null + secondsPerDay: number | null + minutesPerDay: number | null + hoursPerDay: number | null + duration: RocketlaneResourceAllocationDuration | null + allocationType: string | null + allocationFor: string | null + project: RocketlaneResourceAllocationProject | null + tasks: RocketlaneResourceAllocationTask[] + member: RocketlaneResourceAllocationMember | null + placeholder: RocketlaneResourceAllocationPlaceholder | null + createdAt: number | null + updatedAt: number | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null +} + +function mapResourceAllocationRole(value: unknown): RocketlaneResourceAllocationRole | null { + const raw = asObject(value) + if (!raw) return null + return { + roleId: asNumber(raw.roleId), + roleName: asString(raw.roleName), + } +} + +function mapResourceAllocationMember(value: unknown): RocketlaneResourceAllocationMember | null { + const raw = asObject(value) + if (!raw) return null + const user = mapUserSummary(raw) + if (!user) return null + return { + ...user, + role: mapResourceAllocationRole(raw.role), + } +} + +function mapResourceAllocationPlaceholder( + value: unknown +): RocketlaneResourceAllocationPlaceholder | null { + const raw = asObject(value) + if (!raw) return null + return { + placeholderId: asNumber(raw.placeholderId), + placeholderName: asString(raw.placeholderName), + role: mapResourceAllocationRole(raw.role), + } +} + +function mapResourceAllocationDuration( + value: unknown +): RocketlaneResourceAllocationDuration | null { + const raw = asObject(value) + if (!raw) return null + return { + daysConsider: asNumber(raw.daysConsider), + seconds: asNumber(raw.seconds), + minutes: asNumber(raw.minutes), + hours: asNumber(raw.hours), + } +} + +function mapResourceAllocationProject(value: unknown): RocketlaneResourceAllocationProject | null { + const raw = asObject(value) + if (!raw) return null + return { + projectId: asNumber(raw.projectId), + projectName: asString(raw.projectName), + } +} + +function mapResourceAllocationTask(value: unknown): RocketlaneResourceAllocationTask { + const raw = asObject(value) ?? {} + return { + taskId: asNumber(raw.taskId), + taskName: asString(raw.taskName), + } +} + +/** + * Maps a raw resource-allocation payload to the normalized + * {@link RocketlaneResourceAllocation} shape. + */ +export function mapResourceAllocation(value: unknown): RocketlaneResourceAllocation { + const raw = asObject(value) ?? {} + return { + startDate: asString(raw.startDate), + endDate: asString(raw.endDate), + secondsPerDay: asNumber(raw.secondsPerDay), + minutesPerDay: asNumber(raw.minutesPerDay), + hoursPerDay: asNumber(raw.hoursPerDay), + duration: mapResourceAllocationDuration(raw.duration), + allocationType: asString(raw.allocationType), + allocationFor: asString(raw.allocationFor), + project: mapResourceAllocationProject(raw.project), + tasks: asArray(raw.tasks).map(mapResourceAllocationTask), + member: mapResourceAllocationMember(raw.member), + placeholder: mapResourceAllocationPlaceholder(raw.placeholder), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + } +} + +const RESOURCE_ALLOCATION_ROLE_OUTPUT_PROPERTIES = { + roleId: { type: 'number', description: 'Unique identifier of the role', nullable: true }, + roleName: { type: 'string', description: 'Name of the role', nullable: true }, +} satisfies Record + +export const RESOURCE_ALLOCATION_OUTPUT_PROPERTIES = { + startDate: { + type: 'string', + description: 'Allocation start date (YYYY-MM-DD)', + nullable: true, + }, + endDate: { type: 'string', description: 'Allocation end date (YYYY-MM-DD)', nullable: true }, + secondsPerDay: { type: 'number', description: 'Allocated seconds per day', nullable: true }, + minutesPerDay: { type: 'number', description: 'Allocated minutes per day', nullable: true }, + hoursPerDay: { type: 'number', description: 'Allocated hours per day', nullable: true }, + duration: { + type: 'object', + description: 'Total allocation duration between the start and end dates', + nullable: true, + properties: { + daysConsider: { + type: 'number', + description: 'Number of week days considered for the duration computation', + nullable: true, + }, + seconds: { type: 'number', description: 'Total allocation seconds', nullable: true }, + minutes: { type: 'number', description: 'Total allocation minutes', nullable: true }, + hours: { type: 'number', description: 'Total allocation hours', nullable: true }, + }, + }, + allocationType: { + type: 'string', + description: 'Type of allocation (SOFT or HARD)', + nullable: true, + }, + allocationFor: { + type: 'string', + description: 'Who the allocation is for (TEAM_MEMBER or PLACEHOLDER)', + nullable: true, + }, + project: { + type: 'object', + description: 'The project associated with the allocation', + nullable: true, + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + tasks: { + type: 'array', + description: 'Tasks associated with the allocation', + items: { + type: 'object', + properties: { + taskId: { type: 'number', description: 'Unique identifier of the task', nullable: true }, + taskName: { type: 'string', description: 'Name of the task', nullable: true }, + }, + }, + }, + member: { + type: 'object', + description: 'The team member allocated when allocationFor is TEAM_MEMBER', + nullable: true, + properties: { + ...USER_SUMMARY_OUTPUT_PROPERTIES, + role: { + type: 'object', + description: 'Role of the member', + nullable: true, + properties: RESOURCE_ALLOCATION_ROLE_OUTPUT_PROPERTIES, + }, + }, + }, + placeholder: { + type: 'object', + description: 'The placeholder allocated when allocationFor is PLACEHOLDER', + nullable: true, + properties: { + placeholderId: { + type: 'number', + description: 'Unique identifier of the placeholder', + nullable: true, + }, + placeholderName: { + type: 'string', + description: 'Name of the placeholder', + nullable: true, + }, + role: { + type: 'object', + description: 'Role of the placeholder', + nullable: true, + properties: RESOURCE_ALLOCATION_ROLE_OUTPUT_PROPERTIES, + }, + }, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the allocation was created (epoch milliseconds)', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Timestamp when the allocation was last updated (epoch milliseconds)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'The team member who created the allocation', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'The team member who last updated the allocation', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, +} satisfies Record + +/** Response containing a page of resource allocations. */ +export interface RocketlaneResourceAllocationListResponse extends ToolResponse { + output: { + allocations: RocketlaneResourceAllocation[] + pagination: RocketlanePagination + } +} + +// endregion + +// region Invoices + +/** Params for fetching a single invoice by ID. */ +export interface RocketlaneInvoiceGetParams extends RocketlaneBaseParams { + invoiceId: number + includeFields?: string[] + includeAllFields?: boolean +} + +/** Params for searching invoices with filters, sorting, and pagination. */ +export interface RocketlaneInvoiceListParams extends RocketlaneBaseParams { + pageSize?: number + pageToken?: string + includeFields?: string[] + includeAllFields?: boolean + sortBy?: string + sortOrder?: string + match?: string + dateOfIssueEq?: string + dateOfIssueGt?: string + dateOfIssueGe?: string + dateOfIssueLt?: string + dateOfIssueLe?: string + dueDateEq?: string + dueDateGt?: string + dueDateGe?: string + dueDateLt?: string + dueDateLe?: string + amountEq?: number + amountGt?: number + amountGe?: number + amountLt?: number + amountLe?: number + amountOutstandingEq?: number + amountOutstandingGt?: number + amountOutstandingGe?: number + amountOutstandingLt?: number + amountOutstandingLe?: number + amountPaidEq?: number + amountPaidGt?: number + amountPaidGe?: number + amountPaidLt?: number + amountPaidLe?: number + amountWrittenOffEq?: number + amountWrittenOffGt?: number + amountWrittenOffGe?: number + amountWrittenOffLt?: number + amountWrittenOffLe?: number + createdAtEq?: number + createdAtGt?: number + createdAtGe?: number + createdAtLt?: number + createdAtLe?: number + companyIdEq?: string + companyIdOneOf?: string + companyIdNoneOf?: string + invoiceNumberEq?: string + invoiceNumberCn?: string + invoiceNumberNc?: string + statusEq?: string + statusOneOf?: string + statusNoneOf?: string +} + +/** Params for listing payments recorded against an invoice. */ +export interface RocketlaneInvoicePaymentsParams extends RocketlaneBaseParams { + invoiceId: number + pageSize?: number + pageToken?: string +} + +/** Params for listing line items of an invoice. */ +export interface RocketlaneInvoiceLineItemsParams extends RocketlaneBaseParams { + invoiceId: number + pageSize?: number + pageToken?: string +} + +/** Customer company details on an invoice. */ +export interface RocketlaneInvoiceCompany { + companyId: number | null + companyName: string | null + companyUrl: string | null +} + +/** A project mapped to an invoice. */ +export interface RocketlaneInvoiceProject { + projectId: number | null + projectName: string | null +} + +/** A custom field value attached to an invoice. */ +export interface RocketlaneInvoiceField { + fieldId: number | null + fieldLabel: string | null + fieldValue: unknown + fieldValueLabel: string | null +} + +/** An attachment associated with an invoice. */ +export interface RocketlaneInvoiceAttachment { + attachmentId: number | null + attachmentName: string | null + createdAt: number | null + location: string | null + thumbLocation: string | null + visibility: boolean | null +} + +/** A Rocketlane invoice. */ +export interface RocketlaneInvoice { + invoiceId: number | null + invoiceNumber: string | null + dateOfIssue: string | null + dueDate: string | null + currency: string | null + status: string | null + amount: number | null + tax: number | null + subTotal: number | null + amountOutstanding: number | null + amountPaid: number | null + amountWrittenOff: number | null + notes: string | null + createdAt: number | null + updatedAt: number | null + createdBy: RocketlaneUserSummary | null + updatedBy: RocketlaneUserSummary | null + company: RocketlaneInvoiceCompany | null + projects: RocketlaneInvoiceProject[] + fields: RocketlaneInvoiceField[] + attachments: RocketlaneInvoiceAttachment[] +} + +/** A payment recorded against an invoice. */ +export interface RocketlaneInvoicePayment { + paymentId: number | null + paymentRecordType: string | null + currency: string | null + paymentDate: string | null + amount: number | null + notes: string | null +} + +/** Tax code information for an invoice line item. */ +export interface RocketlaneInvoiceLineItemTaxCode { + taxCodeId: number | null + taxCodeName: string | null + taxCodeRate: number | null + taxCodeAmount: number | null +} + +/** A tax component that makes up a line item's tax code. */ +export interface RocketlaneInvoiceLineItemTaxComponent { + taxComponentId: number | null + taxComponentName: string | null + taxComponentRate: number | null + taxComponentAmount: number | null + taxComponentType: string | null +} + +/** A line item on an invoice. */ +export interface RocketlaneInvoiceLineItem { + invoiceLineItemId: number | null + description: string | null + quantity: number | null + unitPrice: number | null + amount: number | null + sourceId: number | null + sourceType: string | null + taxCode: RocketlaneInvoiceLineItemTaxCode | null + taxComponents: RocketlaneInvoiceLineItemTaxComponent[] +} + +function mapInvoiceCompany(value: unknown): RocketlaneInvoiceCompany | null { + const raw = asObject(value) + if (!raw) return null + return { + companyId: asNumber(raw.companyId), + companyName: asString(raw.companyName), + companyUrl: asString(raw.companyUrl), + } +} + +function mapInvoiceProject(value: unknown): RocketlaneInvoiceProject { + const raw = asObject(value) ?? {} + return { + projectId: asNumber(raw.projectId), + projectName: asString(raw.projectName), + } +} + +function mapInvoiceField(value: unknown): RocketlaneInvoiceField { + const raw = asObject(value) ?? {} + return { + fieldId: asNumber(raw.fieldId), + fieldLabel: asString(raw.fieldLabel), + fieldValue: raw.fieldValue ?? null, + fieldValueLabel: asString(raw.fieldValueLabel), + } +} + +function mapInvoiceAttachment(value: unknown): RocketlaneInvoiceAttachment { + const raw = asObject(value) ?? {} + return { + attachmentId: asNumber(raw.attachmentId), + attachmentName: asString(raw.attachmentName), + createdAt: asNumber(raw.createdAt), + location: asString(raw.location), + thumbLocation: asString(raw.thumbLocation), + visibility: asBoolean(raw.visibility), + } +} + +/** + * Maps a raw invoice payload to the normalized {@link RocketlaneInvoice} shape. + */ +export function mapInvoice(value: unknown): RocketlaneInvoice { + const raw = asObject(value) ?? {} + return { + invoiceId: asNumber(raw.invoiceId), + invoiceNumber: asString(raw.invoiceNumber), + dateOfIssue: asString(raw.dateOfIssue), + dueDate: asString(raw.dueDate), + currency: asString(raw.currency), + status: asString(raw.status), + amount: asNumber(raw.amount), + tax: asNumber(raw.tax), + subTotal: asNumber(raw.subTotal), + amountOutstanding: asNumber(raw.amountOutstanding), + amountPaid: asNumber(raw.amountPaid), + amountWrittenOff: asNumber(raw.amountWrittenOff), + notes: asString(raw.notes), + createdAt: asNumber(raw.createdAt), + updatedAt: asNumber(raw.updatedAt), + createdBy: mapUserSummary(raw.createdBy), + updatedBy: mapUserSummary(raw.updatedBy), + company: mapInvoiceCompany(raw.company), + projects: asArray(raw.projects).map(mapInvoiceProject), + fields: asArray(raw.fields).map(mapInvoiceField), + attachments: asArray(raw.attachments).map(mapInvoiceAttachment), + } +} + +/** + * Maps a raw payment-record payload to the normalized {@link RocketlaneInvoicePayment} shape. + */ +export function mapInvoicePayment(value: unknown): RocketlaneInvoicePayment { + const raw = asObject(value) ?? {} + return { + paymentId: asNumber(raw.paymentId), + paymentRecordType: asString(raw.paymentRecordType), + currency: asString(raw.currency), + paymentDate: asString(raw.paymentDate), + amount: asNumber(raw.amount), + notes: asString(raw.notes), + } +} + +function mapInvoiceLineItemTaxCode(value: unknown): RocketlaneInvoiceLineItemTaxCode | null { + const raw = asObject(value) + if (!raw) return null + return { + taxCodeId: asNumber(raw.taxCodeId), + taxCodeName: asString(raw.taxCodeName), + taxCodeRate: asNumber(raw.taxCodeRate), + taxCodeAmount: asNumber(raw.taxCodeAmount), + } +} + +function mapInvoiceLineItemTaxComponent(value: unknown): RocketlaneInvoiceLineItemTaxComponent { + const raw = asObject(value) ?? {} + return { + taxComponentId: asNumber(raw.taxComponentId), + taxComponentName: asString(raw.taxComponentName), + taxComponentRate: asNumber(raw.taxComponentRate), + taxComponentAmount: asNumber(raw.taxComponentAmount), + taxComponentType: asString(raw.taxComponentType), + } +} + +/** + * Maps a raw invoice line-item payload to the normalized {@link RocketlaneInvoiceLineItem} shape. + */ +export function mapInvoiceLineItem(value: unknown): RocketlaneInvoiceLineItem { + const raw = asObject(value) ?? {} + return { + invoiceLineItemId: asNumber(raw.invoiceLineItemId), + description: asString(raw.description), + quantity: asNumber(raw.quantity), + unitPrice: asNumber(raw.unitPrice), + amount: asNumber(raw.amount), + sourceId: asNumber(raw.sourceId), + sourceType: asString(raw.sourceType), + taxCode: mapInvoiceLineItemTaxCode(raw.taxCode), + taxComponents: asArray(raw.taxComponents).map(mapInvoiceLineItemTaxComponent), + } +} + +export const INVOICE_OUTPUT_PROPERTIES = { + invoiceId: { type: 'number', description: 'Unique identifier of the invoice', nullable: true }, + invoiceNumber: { + type: 'string', + description: 'Invoice number assigned to this invoice', + nullable: true, + }, + dateOfIssue: { + type: 'string', + description: 'Date when the invoice was issued (YYYY-MM-DD)', + nullable: true, + }, + dueDate: { + type: 'string', + description: 'Due date for the invoice payment (YYYY-MM-DD)', + nullable: true, + }, + currency: { + type: 'string', + description: 'Currency of the invoice amount (e.g. USD)', + nullable: true, + }, + status: { type: 'string', description: 'Current status of the invoice', nullable: true }, + amount: { + type: 'number', + description: 'Total amount of the invoice including tax', + nullable: true, + }, + tax: { type: 'number', description: 'Tax amount applied to the invoice', nullable: true }, + subTotal: { + type: 'number', + description: 'Total amount of the invoice excluding tax', + nullable: true, + }, + amountOutstanding: { + type: 'number', + description: 'Balance amount remaining to be paid', + nullable: true, + }, + amountPaid: { + type: 'number', + description: 'Total amount paid for this invoice', + nullable: true, + }, + amountWrittenOff: { + type: 'number', + description: 'Total amount written off for this invoice', + nullable: true, + }, + notes: { + type: 'string', + description: 'Notes or additional information about the invoice', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the invoice was created (epoch milliseconds)', + nullable: true, + }, + updatedAt: { + type: 'number', + description: 'Timestamp when the invoice was last updated (epoch milliseconds)', + nullable: true, + }, + createdBy: { + type: 'object', + description: 'The team member who created the invoice', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + updatedBy: { + type: 'object', + description: 'The team member who last updated the invoice', + nullable: true, + properties: USER_SUMMARY_OUTPUT_PROPERTIES, + }, + company: { + type: 'object', + description: 'Customer company details for the invoice', + nullable: true, + properties: { + companyId: { + type: 'number', + description: 'Unique identifier of the customer company', + nullable: true, + }, + companyName: { + type: 'string', + description: 'Name of the customer company', + nullable: true, + }, + companyUrl: { + type: 'string', + description: 'URL of the customer company website', + nullable: true, + }, + }, + }, + projects: { + type: 'array', + description: 'Projects mapped to this invoice', + items: { + type: 'object', + properties: { + projectId: { + type: 'number', + description: 'Unique identifier of the project', + nullable: true, + }, + projectName: { type: 'string', description: 'Name of the project', nullable: true }, + }, + }, + }, + fields: { + type: 'array', + description: 'Custom invoice fields with their values', + items: { + type: 'object', + properties: { + fieldId: { + type: 'number', + description: 'Unique identifier of the field', + nullable: true, + }, + fieldLabel: { type: 'string', description: 'Label of the field', nullable: true }, + fieldValue: { + type: 'json', + description: 'Value of the field (string, number, or array depending on field type)', + nullable: true, + }, + fieldValueLabel: { + type: 'string', + description: 'String representation of the field value', + nullable: true, + }, + }, + }, + }, + attachments: { + type: 'array', + description: 'Attachments associated with the invoice', + items: { + type: 'object', + properties: { + attachmentId: { + type: 'number', + description: 'Unique identifier of the attachment', + nullable: true, + }, + attachmentName: { + type: 'string', + description: 'Name of the attachment', + nullable: true, + }, + createdAt: { + type: 'number', + description: 'Timestamp when the attachment was created (epoch milliseconds)', + nullable: true, + }, + location: { type: 'string', description: 'URL of the attachment', nullable: true }, + thumbLocation: { + type: 'string', + description: 'Thumbnail URL of the attachment', + nullable: true, + }, + visibility: { + type: 'boolean', + description: 'Visibility of the attachment', + nullable: true, + }, + }, + }, + }, +} satisfies Record + +export const INVOICE_PAYMENT_OUTPUT_PROPERTIES = { + paymentId: { + type: 'number', + description: 'Unique identifier of the payment record', + nullable: true, + }, + paymentRecordType: { + type: 'string', + description: 'Type of the payment record (PAID or WRITE_OFF)', + nullable: true, + }, + currency: { + type: 'string', + description: 'Currency of the payment amount (e.g. USD)', + nullable: true, + }, + paymentDate: { + type: 'string', + description: 'Date when the payment was made (YYYY-MM-DD)', + nullable: true, + }, + amount: { type: 'number', description: 'Amount of the payment', nullable: true }, + notes: { + type: 'string', + description: 'Additional notes or comments regarding the payment', + nullable: true, + }, +} satisfies Record + +export const INVOICE_LINE_ITEM_OUTPUT_PROPERTIES = { + invoiceLineItemId: { + type: 'number', + description: 'Unique identifier of the invoice line item', + nullable: true, + }, + description: { + type: 'string', + description: 'Description of the line item or service provided', + nullable: true, + }, + quantity: { type: 'number', description: 'Quantity of the item or service', nullable: true }, + unitPrice: { + type: 'number', + description: 'Unit price for the item or service', + nullable: true, + }, + amount: { + type: 'number', + description: 'Total amount for this line item (quantity times unit price)', + nullable: true, + }, + sourceId: { + type: 'number', + description: 'Unique identifier of the source entity (e.g. project ID)', + nullable: true, + }, + sourceType: { + type: 'string', + description: 'Type of source entity this line item is associated with (e.g. PROJECT)', + nullable: true, + }, + taxCode: { + type: 'object', + description: 'Tax code information for this line item', + nullable: true, + properties: { + taxCodeId: { + type: 'number', + description: 'Unique identifier of the tax code', + nullable: true, + }, + taxCodeName: { type: 'string', description: 'Name of the tax code', nullable: true }, + taxCodeRate: { + type: 'number', + description: 'Tax rate percentage for the tax code', + nullable: true, + }, + taxCodeAmount: { + type: 'number', + description: 'Tax amount calculated for this tax code', + nullable: true, + }, + }, + }, + taxComponents: { + type: 'array', + description: 'Tax components that make up the tax code', + items: { + type: 'object', + properties: { + taxComponentId: { + type: 'number', + description: 'Unique identifier of the tax component', + nullable: true, + }, + taxComponentName: { + type: 'string', + description: 'Name of the tax component', + nullable: true, + }, + taxComponentRate: { + type: 'number', + description: 'Tax rate percentage for the tax component', + nullable: true, + }, + taxComponentAmount: { + type: 'number', + description: 'Tax amount calculated for this tax component', + nullable: true, + }, + taxComponentType: { + type: 'string', + description: 'Type of the tax component (e.g. GST, VAT)', + nullable: true, + }, + }, + }, + }, +} satisfies Record + +/** Response containing a single invoice. */ +export interface RocketlaneInvoiceResponse extends ToolResponse { + output: { + invoice: RocketlaneInvoice + } +} + +/** Response containing a page of invoices. */ +export interface RocketlaneInvoiceListResponse extends ToolResponse { + output: { + invoices: RocketlaneInvoice[] + pagination: RocketlanePagination + } +} + +/** Response containing a page of invoice payments. */ +export interface RocketlaneInvoicePaymentListResponse extends ToolResponse { + output: { + payments: RocketlaneInvoicePayment[] + pagination: RocketlanePagination + } +} + +/** Response containing a page of invoice line items. */ +export interface RocketlaneInvoiceLineItemListResponse extends ToolResponse { + output: { + lineItems: RocketlaneInvoiceLineItem[] + pagination: RocketlanePagination + } +} + +// endregion diff --git a/apps/sim/tools/rocketlane/unassign_placeholders.ts b/apps/sim/tools/rocketlane/unassign_placeholders.ts new file mode 100644 index 00000000000..bf207d8ffbf --- /dev/null +++ b/apps/sim/tools/rocketlane/unassign_placeholders.ts @@ -0,0 +1,80 @@ +import { + mapPlaceholderMapping, + mapProject, + PLACEHOLDER_MAPPING_OUTPUT_PROPERTIES, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneProjectPlaceholdersResponse, + type RocketlaneUnassignPlaceholdersParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUnassignPlaceholdersTool: ToolConfig< + RocketlaneUnassignPlaceholdersParams, + RocketlaneProjectPlaceholdersResponse +> = { + id: 'rocketlane_unassign_placeholders', + name: 'Rocketlane Unassign Placeholders', + description: 'Unassign a placeholder from its user in a Rocketlane project', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project', + }, + placeholderId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the placeholder to unassign', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}/unassign-placeholders`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => [{ placeholderId: params.placeholderId }], + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { + project: mapProject(data), + placeholders: Array.isArray(data?.placeholders) + ? data.placeholders.map(mapPlaceholderMapping) + : [], + }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The project after the placeholder was unassigned', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + placeholders: { + type: 'array', + description: 'Placeholder-to-user mappings on the project', + items: { type: 'object', properties: PLACEHOLDER_MAPPING_OUTPUT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_field.ts b/apps/sim/tools/rocketlane/update_field.ts new file mode 100644 index 00000000000..2efbdec1955 --- /dev/null +++ b/apps/sim/tools/rocketlane/update_field.ts @@ -0,0 +1,113 @@ +import { + FIELD_OUTPUT_PROPERTIES, + mapField, + ROCKETLANE_API_BASE, + type RocketlaneFieldResponse, + type RocketlaneUpdateFieldParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateFieldTool: ToolConfig< + RocketlaneUpdateFieldParams, + RocketlaneFieldResponse +> = { + id: 'rocketlane_update_field', + name: 'Rocketlane Update Field', + description: + 'Update the label, description, enabled state, or privacy of an existing Rocketlane field', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the field to update', + }, + fieldLabel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the field', + }, + fieldDescription: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New description of the field', + }, + enabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the field is enabled', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the field is private', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra field properties to include in the response (supported: options)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all field properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/fields/${encodeURIComponent(String(params.fieldId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + ...(params.fieldLabel != null && { fieldLabel: params.fieldLabel }), + ...(params.fieldDescription != null && { fieldDescription: params.fieldDescription }), + ...(params.enabled != null && { enabled: params.enabled }), + ...(params.private != null && { private: params.private }), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { field: mapField(data) }, + } + }, + + outputs: { + field: { + type: 'object', + description: 'The updated field', + properties: FIELD_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_field_option.ts b/apps/sim/tools/rocketlane/update_field_option.ts new file mode 100644 index 00000000000..5730aef43b7 --- /dev/null +++ b/apps/sim/tools/rocketlane/update_field_option.ts @@ -0,0 +1,86 @@ +import { + FIELD_OPTION_OUTPUT_PROPERTIES, + mapFieldOption, + ROCKETLANE_API_BASE, + type RocketlaneFieldOptionResponse, + type RocketlaneUpdateFieldOptionParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateFieldOptionTool: ToolConfig< + RocketlaneUpdateFieldOptionParams, + RocketlaneFieldOptionResponse +> = { + id: 'rocketlane_update_field_option', + name: 'Rocketlane Update Field Option', + description: + 'Update the label or color of an existing option on a SINGLE_CHOICE or MULTIPLE_CHOICE Rocketlane field', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + fieldId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the field the option belongs to', + }, + optionValue: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the option to update', + }, + optionLabel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New display label of the option', + }, + optionColor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'New color of the option (RED, YELLOW, GREEN, TEAL, CYAN, BLUE, PURPLE, MAGENTA, GRAY, COOL_GRAY)', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/fields/${encodeURIComponent(String(params.fieldId))}/update-option`, + method: 'POST', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + optionValue: params.optionValue, + ...(params.optionLabel != null && { optionLabel: params.optionLabel }), + ...(params.optionColor != null && { optionColor: params.optionColor }), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { option: mapFieldOption(data) }, + } + }, + + outputs: { + option: { + type: 'object', + description: 'The updated field option', + properties: FIELD_OPTION_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_phase.ts b/apps/sim/tools/rocketlane/update_phase.ts new file mode 100644 index 00000000000..6446ee2512f --- /dev/null +++ b/apps/sim/tools/rocketlane/update_phase.ts @@ -0,0 +1,119 @@ +import { + mapPhase, + PHASE_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlanePhaseResponse, + type RocketlaneUpdatePhaseParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdatePhaseTool: ToolConfig< + RocketlaneUpdatePhaseParams, + RocketlanePhaseResponse +> = { + id: 'rocketlane_update_phase', + name: 'Rocketlane Update Phase', + description: 'Update the name, dates, status, or privacy of an existing Rocketlane phase', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + phaseId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the phase to update', + }, + phaseName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the phase', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New planned start date of the phase (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New planned due date of the phase (YYYY-MM-DD)', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'New numeric status value for the phase', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the phase is private', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra phase properties to include in the response (supported: startDateActual, dueDateActual)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return all phase properties in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/phases/${encodeURIComponent(String(params.phaseId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => ({ + ...(params.phaseName != null && { phaseName: params.phaseName }), + ...(params.startDate != null && { startDate: params.startDate }), + ...(params.dueDate != null && { dueDate: params.dueDate }), + ...(params.statusValue != null && { status: { value: params.statusValue } }), + ...(params.private != null && { private: params.private }), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { phase: mapPhase(data) }, + } + }, + + outputs: { + phase: { + type: 'object', + description: 'The updated phase', + properties: PHASE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_project.ts b/apps/sim/tools/rocketlane/update_project.ts new file mode 100644 index 00000000000..8487c89fffc --- /dev/null +++ b/apps/sim/tools/rocketlane/update_project.ts @@ -0,0 +1,195 @@ +import { + mapProject, + PROJECT_OUTPUT_PROPERTIES, + ROCKETLANE_API_BASE, + type RocketlaneProjectResponse, + type RocketlaneUpdateProjectParams, + rocketlaneError, + rocketlaneHeaders, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateProjectTool: ToolConfig< + RocketlaneUpdateProjectParams, + RocketlaneProjectResponse +> = { + id: 'rocketlane_update_project', + name: 'Rocketlane Update Project', + description: + 'Update a Rocketlane project by ID, including name, dates, visibility, owner, status, and custom fields', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the project to update', + }, + projectName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the project', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date on which the project begins (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Date on which the project is planned to complete (YYYY-MM-DD, on or after startDate)', + }, + visibility: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Who can see the project: EVERYONE or MEMBERS', + }, + ownerUserId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'User ID of the new project owner (transfers ownership and revokes access for the previous owner)', + }, + ownerEmailId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email of the new project owner', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Value (identifier) of the project status', + }, + fields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Custom field assignments, each with fieldId and fieldValue (string, number, or number array matching the field type)', + items: { + type: 'object', + description: 'Custom field assignment', + properties: { + fieldId: { type: 'number', description: 'Unique identifier of the field' }, + fieldValue: { + type: 'string', + description: 'Value of the field (string, number, or number array)', + }, + }, + }, + }, + annualizedRecurringRevenue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Recurring revenue of the customer subscriptions for a single calendar year', + }, + projectFee: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Total fee charged for the project', + }, + autoAllocation: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether auto allocation is enabled for the project', + }, + budgetedHours: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Total hours allocated for project execution (decimal, up to two places)', + }, + externalReferenceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identifier linking the project to an external system', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to return in the response (e.g. budgetedHours,progressPercentage)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return all fields in the response body', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/projects/${encodeURIComponent(String(params.projectId))}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + return url.toString() + }, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = {} + if (params.projectName !== undefined) body.projectName = params.projectName + if (params.startDate !== undefined) body.startDate = params.startDate + if (params.dueDate !== undefined) body.dueDate = params.dueDate + if (params.visibility) body.visibility = params.visibility + const owner: Record = {} + if (params.ownerUserId != null) owner.userId = params.ownerUserId + if (params.ownerEmailId) owner.emailId = params.ownerEmailId + if (Object.keys(owner).length > 0) body.owner = owner + if (params.statusValue != null) body.status = { value: params.statusValue } + if (params.fields && params.fields.length > 0) body.fields = params.fields + if (params.annualizedRecurringRevenue != null) + body.annualizedRecurringRevenue = params.annualizedRecurringRevenue + if (params.projectFee != null) body.projectFee = params.projectFee + if (params.autoAllocation != null) body.autoAllocation = params.autoAllocation + if (params.budgetedHours != null) body.budgetedHours = params.budgetedHours + if (params.externalReferenceId !== undefined) + body.externalReferenceId = params.externalReferenceId + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { project: mapProject(data) }, + } + }, + + outputs: { + project: { + type: 'object', + description: 'The updated project', + properties: PROJECT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_space.ts b/apps/sim/tools/rocketlane/update_space.ts new file mode 100644 index 00000000000..baaf34c70c8 --- /dev/null +++ b/apps/sim/tools/rocketlane/update_space.ts @@ -0,0 +1,71 @@ +import { + mapSpace, + ROCKETLANE_API_BASE, + type RocketlaneSpaceResponse, + type RocketlaneUpdateSpaceParams, + rocketlaneError, + rocketlaneHeaders, + SPACE_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateSpaceTool: ToolConfig< + RocketlaneUpdateSpaceParams, + RocketlaneSpaceResponse +> = { + id: 'rocketlane_update_space', + name: 'Rocketlane Update Space', + description: 'Update a Rocketlane space by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space to update', + }, + spaceName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the space', + }, + }, + + request: { + url: (params) => `${ROCKETLANE_API_BASE}/spaces/${encodeURIComponent(params.spaceId)}`, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = {} + if (params.spaceName != null) body.spaceName = params.spaceName + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { space: mapSpace(data) }, + } + }, + + outputs: { + space: { + type: 'object', + description: 'The updated space', + properties: SPACE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_space_document.ts b/apps/sim/tools/rocketlane/update_space_document.ts new file mode 100644 index 00000000000..5f898c699f5 --- /dev/null +++ b/apps/sim/tools/rocketlane/update_space_document.ts @@ -0,0 +1,79 @@ +import { + mapSpaceDocument, + ROCKETLANE_API_BASE, + type RocketlaneSpaceDocumentResponse, + type RocketlaneUpdateSpaceDocumentParams, + rocketlaneError, + rocketlaneHeaders, + SPACE_DOCUMENT_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateSpaceDocumentTool: ToolConfig< + RocketlaneUpdateSpaceDocumentParams, + RocketlaneSpaceDocumentResponse +> = { + id: 'rocketlane_update_space_document', + name: 'Rocketlane Update Space Document', + description: 'Update a Rocketlane space document by its ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + spaceDocumentId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the space document to update', + }, + spaceDocumentName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the space document', + }, + url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New URL to embed in the space document (for embedded documents)', + }, + }, + + request: { + url: (params) => + `${ROCKETLANE_API_BASE}/space-documents/${encodeURIComponent(params.spaceDocumentId)}`, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = {} + if (params.spaceDocumentName != null) body.spaceDocumentName = params.spaceDocumentName + if (params.url != null) body.url = params.url + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { spaceDocument: mapSpaceDocument(data) }, + } + }, + + outputs: { + spaceDocument: { + type: 'object', + description: 'The updated space document', + properties: SPACE_DOCUMENT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_task.ts b/apps/sim/tools/rocketlane/update_task.ts new file mode 100644 index 00000000000..33c34bcac6e --- /dev/null +++ b/apps/sim/tools/rocketlane/update_task.ts @@ -0,0 +1,175 @@ +import { + mapTask, + ROCKETLANE_API_BASE, + type RocketlaneTaskResponse, + type RocketlaneUpdateTaskParams, + rocketlaneError, + rocketlaneHeaders, + TASK_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateTaskTool: ToolConfig< + RocketlaneUpdateTaskParams, + RocketlaneTaskResponse +> = { + id: 'rocketlane_update_task', + name: 'Rocketlane Update Task', + description: 'Update the properties of an existing Rocketlane task by its unique identifier', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + taskId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the task to update', + }, + taskName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the task', + }, + taskDescription: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the task in HTML format', + }, + taskPrivateNote: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Private note visible only to team members, in HTML format', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date when the task starts (YYYY-MM-DD)', + }, + dueDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date when the task is due, on or after the start date (YYYY-MM-DD)', + }, + effortInMinutes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Expected effort to complete the task, in minutes', + }, + progress: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Progress of the task (0-100)', + }, + atRisk: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the task is marked as At Risk', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Type of the task: TASK or MILESTONE', + }, + statusValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Status value to set on the task', + }, + externalReferenceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'External reference identifier linking the task to an external system', + }, + private: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the task is private', + }, + includeFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Extra fields to include in the response (startDateActual, dueDateActual, type, phase, assignees, followers, dependencies, billable, csatEnabled, priority, timeEntryCategory, financialsBudget, taskPrivateNote, parent, externalReferenceId)', + items: { type: 'string' }, + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/tasks/${encodeURIComponent(String(params.taskId))}` + ) + if (params.includeFields && params.includeFields.length > 0) { + url.searchParams.set('includeFields', params.includeFields.join(',')) + } + if (params.includeAllFields !== undefined) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = {} + if (params.taskName !== undefined) body.taskName = params.taskName + if (params.taskDescription !== undefined) body.taskDescription = params.taskDescription + if (params.taskPrivateNote !== undefined) body.taskPrivateNote = params.taskPrivateNote + if (params.startDate !== undefined) body.startDate = params.startDate + if (params.dueDate !== undefined) body.dueDate = params.dueDate + if (params.effortInMinutes !== undefined) body.effortInMinutes = params.effortInMinutes + if (params.progress !== undefined) body.progress = params.progress + if (params.atRisk !== undefined) body.atRisk = params.atRisk + if (params.type !== undefined) body.type = params.type + if (params.statusValue !== undefined) body.status = { value: params.statusValue } + if (params.externalReferenceId !== undefined) { + body.externalReferenceId = params.externalReferenceId + } + if (params.private !== undefined) body.private = params.private + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { task: mapTask(data) }, + } + }, + + outputs: { + task: { + type: 'object', + description: 'The updated task', + properties: TASK_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/rocketlane/update_time_entry.ts b/apps/sim/tools/rocketlane/update_time_entry.ts new file mode 100644 index 00000000000..429fa806c01 --- /dev/null +++ b/apps/sim/tools/rocketlane/update_time_entry.ts @@ -0,0 +1,131 @@ +import { + mapTimeEntry, + ROCKETLANE_API_BASE, + type RocketlaneTimeEntryResponse, + type RocketlaneUpdateTimeEntryParams, + rocketlaneError, + rocketlaneHeaders, + TIME_ENTRY_OUTPUT_PROPERTIES, +} from '@/tools/rocketlane/types' +import type { ToolConfig } from '@/tools/types' + +export const rocketlaneUpdateTimeEntryTool: ToolConfig< + RocketlaneUpdateTimeEntryParams, + RocketlaneTimeEntryResponse +> = { + id: 'rocketlane_update_time_entry', + name: 'Rocketlane Update Time Entry', + description: + 'Update a Rocketlane time entry by ID. The activityName, notes, billable, and minutes properties can be updated', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Rocketlane API key', + }, + timeEntryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'ID of the time entry to update', + }, + date: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Date of the time entry in YYYY-MM-DD format (mandatory so the total time for the date does not exceed 24 hours)', + }, + minutes: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Duration of the time entry in minutes (1-1440)', + }, + activityName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the adhoc activity', + }, + notes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New notes for the time entry', + }, + billable: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the time entry is billable', + }, + categoryId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'ID of the time entry category', + }, + includeFields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated extra fields to include in the response (notes, sourceType, deleted, status, submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectedAt, costRate, billRate)', + }, + includeAllFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether all fields should be returned in the response', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `${ROCKETLANE_API_BASE}/time-entries/${encodeURIComponent(params.timeEntryId)}` + ) + if (params.includeFields) url.searchParams.set('includeFields', params.includeFields) + if (params.includeAllFields != null) { + url.searchParams.set('includeAllFields', String(params.includeAllFields)) + } + return url.toString() + }, + method: 'PUT', + headers: (params) => rocketlaneHeaders(params.apiKey), + body: (params) => { + const body: Record = { + date: params.date, + minutes: params.minutes, + } + if (params.activityName !== undefined) body.activityName = params.activityName + if (params.notes !== undefined) body.notes = params.notes + if (params.billable != null) body.billable = params.billable + if (params.categoryId != null) body.category = { categoryId: params.categoryId } + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await rocketlaneError(response)) + } + const data = await response.json() + return { + success: true, + output: { timeEntry: mapTimeEntry(data) }, + } + }, + + outputs: { + timeEntry: { + type: 'object', + description: 'The updated time entry', + properties: TIME_ENTRY_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 542e5e9a620..a004d61ebd9 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -8,6 +8,7 @@ export type BYOKProviderId = | 'google' | 'mistral' | 'zai' + | 'kimi' | 'xai' | 'fireworks' | 'together' diff --git a/apps/sim/tools/zoom/create_meeting.ts b/apps/sim/tools/zoom/create_meeting.ts index 0ebba188b27..242b5cf526e 100644 --- a/apps/sim/tools/zoom/create_meeting.ts +++ b/apps/sim/tools/zoom/create_meeting.ts @@ -21,7 +21,7 @@ export const zoomCreateMeetingTool: ToolConfig> { + const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + | string + | null + if (!credentialId) { + throw new Error('No ClickUp credential selected') + } + try { + const data = await requestJson(clickupWorkspacesSelectorContract, { + body: { credential: credentialId }, + }) + return (data.workspaces ?? []).map((workspace) => ({ + id: workspace.id, + label: workspace.name, + })) + } catch (error) { + logger.error('Error fetching ClickUp workspaces:', error) + throw error + } +} + +/** + * Builds the shared subBlocks for a ClickUp trigger: OAuth credentials, the + * workspace selector the webhook is registered in, optional location scoping + * (space, folder, list, task), and setup instructions. Used by the primary + * trigger (after its dropdown) and all secondary triggers. + */ +export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'triggerCredentials', + title: 'ClickUp Account', + type: 'oauth-input', + serviceId: 'clickup', + requiredScopes: [], + mode: 'trigger', + required: true, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerWorkspaceId', + title: 'Workspace', + type: 'dropdown', + placeholder: 'Select a workspace', + description: 'The ClickUp Workspace the webhook is registered in', + required: true, + options: [], + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + fetchOptions: fetchWorkspaceOptions, + fetchOptionById: async (blockId: string, optionId: string) => { + const workspaces = await fetchWorkspaceOptions(blockId) + return workspaces.find((workspace) => workspace.id === optionId) ?? null + }, + }, + { + id: 'triggerSpaceId', + title: 'Space ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: + 'Only receive events from this space. ClickUp applies the most specific location when several are set', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerFolderId', + title: 'Folder ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: + 'Only receive events from this folder. ClickUp applies the most specific location when several are set', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerListId', + title: 'List ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: + 'Only receive events from this list. ClickUp applies the most specific location when several are set', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerTaskId', + title: 'Task ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: + 'Only receive events for this task. ClickUp applies the most specific location when several are set', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: clickupSetupInstructions(), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} diff --git a/apps/sim/triggers/clickup/task_assignee_updated.ts b/apps/sim/triggers/clickup/task_assignee_updated.ts new file mode 100644 index 00000000000..23466b0d74b --- /dev/null +++ b/apps/sim/triggers/clickup/task_assignee_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskAssigneeUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_assignee_updated', + name: 'ClickUp Task Assignee Updated', + provider: 'clickup', + description: 'Trigger workflow when the assignees of a task change in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_assignee_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_comment_posted.ts b/apps/sim/triggers/clickup/task_comment_posted.ts new file mode 100644 index 00000000000..af3021a419f --- /dev/null +++ b/apps/sim/triggers/clickup/task_comment_posted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCommentPostedTrigger: TriggerConfig = { + id: 'clickup_task_comment_posted', + name: 'ClickUp Task Comment Posted', + provider: 'clickup', + description: 'Trigger workflow when a comment is posted on a task in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_comment_posted'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_comment_updated.ts b/apps/sim/triggers/clickup/task_comment_updated.ts new file mode 100644 index 00000000000..13844b512a0 --- /dev/null +++ b/apps/sim/triggers/clickup/task_comment_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCommentUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_comment_updated', + name: 'ClickUp Task Comment Updated', + provider: 'clickup', + description: 'Trigger workflow when a task comment is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_comment_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_created.ts b/apps/sim/triggers/clickup/task_created.ts new file mode 100644 index 00000000000..acae43ed846 --- /dev/null +++ b/apps/sim/triggers/clickup/task_created.ts @@ -0,0 +1,30 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs, clickupTriggerOptions } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCreatedTrigger: TriggerConfig = { + id: 'clickup_task_created', + name: 'ClickUp Task Created', + provider: 'clickup', + description: 'Trigger workflow when a task is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: clickupTriggerOptions, + value: () => 'clickup_task_created', + required: true, + }, + ...buildClickUpTriggerSubBlocks('clickup_task_created'), + ], + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_deleted.ts b/apps/sim/triggers/clickup/task_deleted.ts new file mode 100644 index 00000000000..cd60f56f5a4 --- /dev/null +++ b/apps/sim/triggers/clickup/task_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskDeletedTrigger: TriggerConfig = { + id: 'clickup_task_deleted', + name: 'ClickUp Task Deleted', + provider: 'clickup', + description: 'Trigger workflow when a task is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_deleted'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_due_date_updated.ts b/apps/sim/triggers/clickup/task_due_date_updated.ts new file mode 100644 index 00000000000..4c220536e95 --- /dev/null +++ b/apps/sim/triggers/clickup/task_due_date_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskDueDateUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_due_date_updated', + name: 'ClickUp Task Due Date Updated', + provider: 'clickup', + description: 'Trigger workflow when the due date of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_due_date_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_moved.ts b/apps/sim/triggers/clickup/task_moved.ts new file mode 100644 index 00000000000..2989d4cf03a --- /dev/null +++ b/apps/sim/triggers/clickup/task_moved.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskMovedTrigger: TriggerConfig = { + id: 'clickup_task_moved', + name: 'ClickUp Task Moved', + provider: 'clickup', + description: 'Trigger workflow when a task is moved to a different list in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_moved'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_priority_updated.ts b/apps/sim/triggers/clickup/task_priority_updated.ts new file mode 100644 index 00000000000..10b4480ab3b --- /dev/null +++ b/apps/sim/triggers/clickup/task_priority_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskPriorityUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_priority_updated', + name: 'ClickUp Task Priority Updated', + provider: 'clickup', + description: 'Trigger workflow when the priority of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_priority_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_status_updated.ts b/apps/sim/triggers/clickup/task_status_updated.ts new file mode 100644 index 00000000000..658ad90b52a --- /dev/null +++ b/apps/sim/triggers/clickup/task_status_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskStatusUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_status_updated', + name: 'ClickUp Task Status Updated', + provider: 'clickup', + description: 'Trigger workflow when the status of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_status_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_tag_updated.ts b/apps/sim/triggers/clickup/task_tag_updated.ts new file mode 100644 index 00000000000..97397617ef5 --- /dev/null +++ b/apps/sim/triggers/clickup/task_tag_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTagUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_tag_updated', + name: 'ClickUp Task Tag Updated', + provider: 'clickup', + description: 'Trigger workflow when the tags of a task change in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_tag_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_time_estimate_updated.ts b/apps/sim/triggers/clickup/task_time_estimate_updated.ts new file mode 100644 index 00000000000..e795946c2d4 --- /dev/null +++ b/apps/sim/triggers/clickup/task_time_estimate_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTimeEstimateUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_time_estimate_updated', + name: 'ClickUp Task Time Estimate Updated', + provider: 'clickup', + description: 'Trigger workflow when the time estimate of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_time_estimate_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_time_tracked_updated.ts b/apps/sim/triggers/clickup/task_time_tracked_updated.ts new file mode 100644 index 00000000000..11414bdbcae --- /dev/null +++ b/apps/sim/triggers/clickup/task_time_tracked_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTimeTrackedUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_time_tracked_updated', + name: 'ClickUp Task Time Tracked Updated', + provider: 'clickup', + description: 'Trigger workflow when the tracked time of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_time_tracked_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_updated.ts b/apps/sim/triggers/clickup/task_updated.ts new file mode 100644 index 00000000000..ad2fca1caa9 --- /dev/null +++ b/apps/sim/triggers/clickup/task_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_updated', + name: 'ClickUp Task Updated', + provider: 'clickup', + description: 'Trigger workflow when a task is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/utils.ts b/apps/sim/triggers/clickup/utils.ts new file mode 100644 index 00000000000..2af811babeb --- /dev/null +++ b/apps/sim/triggers/clickup/utils.ts @@ -0,0 +1,251 @@ +import type { TriggerOutput } from '@/triggers/types' + +export const clickupTriggerOptions = [ + { label: 'Task Created', id: 'clickup_task_created' }, + { label: 'Task Updated', id: 'clickup_task_updated' }, + { label: 'Task Deleted', id: 'clickup_task_deleted' }, + { label: 'Task Status Updated', id: 'clickup_task_status_updated' }, + { label: 'Task Priority Updated', id: 'clickup_task_priority_updated' }, + { label: 'Task Assignee Updated', id: 'clickup_task_assignee_updated' }, + { label: 'Task Due Date Updated', id: 'clickup_task_due_date_updated' }, + { label: 'Task Tag Updated', id: 'clickup_task_tag_updated' }, + { label: 'Task Moved', id: 'clickup_task_moved' }, + { label: 'Task Comment Posted', id: 'clickup_task_comment_posted' }, + { label: 'Task Comment Updated', id: 'clickup_task_comment_updated' }, + { label: 'Task Time Estimate Updated', id: 'clickup_task_time_estimate_updated' }, + { label: 'Task Time Tracked Updated', id: 'clickup_task_time_tracked_updated' }, + { label: 'List Created', id: 'clickup_list_created' }, + { label: 'List Updated', id: 'clickup_list_updated' }, + { label: 'List Deleted', id: 'clickup_list_deleted' }, + { label: 'Folder Created', id: 'clickup_folder_created' }, + { label: 'Folder Updated', id: 'clickup_folder_updated' }, + { label: 'Folder Deleted', id: 'clickup_folder_deleted' }, + { label: 'Space Created', id: 'clickup_space_created' }, + { label: 'Space Updated', id: 'clickup_space_updated' }, + { label: 'Space Deleted', id: 'clickup_space_deleted' }, + { label: 'Goal Created', id: 'clickup_goal_created' }, + { label: 'Goal Updated', id: 'clickup_goal_updated' }, + { label: 'Goal Deleted', id: 'clickup_goal_deleted' }, + { label: 'Key Result Created', id: 'clickup_key_result_created' }, + { label: 'Key Result Updated', id: 'clickup_key_result_updated' }, + { label: 'Key Result Deleted', id: 'clickup_key_result_deleted' }, + { label: 'All Events (Generic Webhook)', id: 'clickup_webhook' }, +] + +/** + * Builds the setup instructions shown in the trigger configuration panel. + * ClickUp webhooks are fully managed by Sim: created on deploy, deleted on + * undeploy, and verified via the per-webhook HMAC secret. + */ +export function clickupSetupInstructions(): string { + const instructions = [ + 'Note: Webhooks are automatically created in ClickUp when you deploy this workflow, and deleted when you undeploy. See the ClickUp webhook documentation for details.', + 'Connect your ClickUp account using the credential selector above.', + 'Select the Workspace the webhook should be registered in.', + 'Optionally scope the webhook to a specific space, folder, list, or task.', + 'Deploy the workflow — a webhook will be created automatically in your ClickUp workspace.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Maps Sim trigger IDs to the exact ClickUp webhook event names. + * The catch-all `clickup_webhook` trigger is handled separately (it + * subscribes with the `*` wildcard). + */ +export const CLICKUP_TRIGGER_EVENT_MAP: Record = { + clickup_task_created: ['taskCreated'], + clickup_task_updated: ['taskUpdated'], + clickup_task_deleted: ['taskDeleted'], + clickup_task_status_updated: ['taskStatusUpdated'], + clickup_task_priority_updated: ['taskPriorityUpdated'], + clickup_task_assignee_updated: ['taskAssigneeUpdated'], + clickup_task_due_date_updated: ['taskDueDateUpdated'], + clickup_task_tag_updated: ['taskTagUpdated'], + clickup_task_moved: ['taskMoved'], + clickup_task_comment_posted: ['taskCommentPosted'], + clickup_task_comment_updated: ['taskCommentUpdated'], + clickup_task_time_estimate_updated: ['taskTimeEstimateUpdated'], + clickup_task_time_tracked_updated: ['taskTimeTrackedUpdated'], + clickup_list_created: ['listCreated'], + clickup_list_updated: ['listUpdated'], + clickup_list_deleted: ['listDeleted'], + clickup_folder_created: ['folderCreated'], + clickup_folder_updated: ['folderUpdated'], + clickup_folder_deleted: ['folderDeleted'], + clickup_space_created: ['spaceCreated'], + clickup_space_updated: ['spaceUpdated'], + clickup_space_deleted: ['spaceDeleted'], + clickup_goal_created: ['goalCreated'], + clickup_goal_updated: ['goalUpdated'], + clickup_goal_deleted: ['goalDeleted'], + clickup_key_result_created: ['keyResultCreated'], + clickup_key_result_updated: ['keyResultUpdated'], + clickup_key_result_deleted: ['keyResultDeleted'], +} + +/** + * Extracts the event name from a ClickUp webhook payload. + * ClickUp payloads are flat: `{ event, webhook_id, task_id | list_id | ..., history_items }`. + */ +export function getClickUpEventType(body: Record): string | undefined { + return typeof body.event === 'string' ? body.event : undefined +} + +/** + * Checks whether a ClickUp webhook payload matches a trigger. + */ +export function isClickUpEventMatch(triggerId: string, body: Record): boolean { + if (triggerId === 'clickup_webhook') { + return true + } + + const eventType = getClickUpEventType(body) + if (!eventType) { + return false + } + + const acceptedEvents = CLICKUP_TRIGGER_EVENT_MAP[triggerId] + return acceptedEvents ? acceptedEvents.includes(eventType) : false +} + +function buildBaseOutputs(): Record { + return { + eventType: { type: 'string', description: 'The ClickUp event name (e.g. taskCreated)' }, + historyItems: { + type: 'json', + description: + 'History items describing what changed (id, type, date, source, user, before, after)', + }, + payload: { type: 'json', description: 'Full raw ClickUp webhook payload' }, + } +} + +/** Outputs for task-family triggers (task, comment, time events). */ +export function buildClickUpTaskOutputs(): Record { + return { + ...buildBaseOutputs(), + taskId: { type: 'string', description: 'ID of the affected task' }, + } +} + +/** Outputs for list triggers. */ +export function buildClickUpListOutputs(): Record { + return { + ...buildBaseOutputs(), + listId: { type: 'string', description: 'ID of the affected list' }, + } +} + +/** Outputs for folder triggers. */ +export function buildClickUpFolderOutputs(): Record { + return { + ...buildBaseOutputs(), + folderId: { type: 'string', description: 'ID of the affected folder' }, + } +} + +/** Outputs for space triggers. */ +export function buildClickUpSpaceOutputs(): Record { + return { + ...buildBaseOutputs(), + spaceId: { type: 'string', description: 'ID of the affected space' }, + } +} + +/** + * Outputs for goal and key result triggers. ClickUp does not document a + * dedicated resource-ID field for these payloads, so only the documented + * common fields are exposed; the full body is available via `payload`. + */ +export function buildClickUpGoalOutputs(): Record { + return buildBaseOutputs() +} + +/** Outputs for the generic catch-all webhook trigger. */ +export function buildClickUpGenericOutputs(): Record { + return { + ...buildBaseOutputs(), + taskId: { type: 'string', description: 'ID of the affected task (task events only)' }, + listId: { type: 'string', description: 'ID of the affected list (list events only)' }, + folderId: { type: 'string', description: 'ID of the affected folder (folder events only)' }, + spaceId: { type: 'string', description: 'ID of the affected space (space events only)' }, + } +} + +function extractBaseFields(body: Record): Record { + return { + eventType: body.event ?? null, + historyItems: Array.isArray(body.history_items) ? body.history_items : [], + payload: body, + } +} + +/** Extracts formatted data from a ClickUp task-family event payload. */ +export function extractClickUpTaskData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + taskId: body.task_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp list event payload. */ +export function extractClickUpListData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + listId: body.list_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp folder event payload. */ +export function extractClickUpFolderData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + folderId: body.folder_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp space event payload. */ +export function extractClickUpSpaceData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + spaceId: body.space_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp goal or key result event payload. */ +export function extractClickUpGoalData(body: Record): Record { + return extractBaseFields(body) +} + +/** Extracts formatted data from any ClickUp event payload (catch-all trigger). */ +export function extractClickUpGenericData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + taskId: body.task_id ?? null, + listId: body.list_id ?? null, + folderId: body.folder_id ?? null, + spaceId: body.space_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} diff --git a/apps/sim/triggers/clickup/webhook.ts b/apps/sim/triggers/clickup/webhook.ts new file mode 100644 index 00000000000..4c03f24f4ee --- /dev/null +++ b/apps/sim/triggers/clickup/webhook.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGenericOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupWebhookTrigger: TriggerConfig = { + id: 'clickup_webhook', + name: 'ClickUp Webhook', + provider: 'clickup', + description: 'Trigger workflow on any ClickUp event (subscribes to all events)', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_webhook'), + outputs: buildClickUpGenericOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/grain/events_v2.ts b/apps/sim/triggers/grain/events_v2.ts new file mode 100644 index 00000000000..b6e8b776c17 --- /dev/null +++ b/apps/sim/triggers/grain/events_v2.ts @@ -0,0 +1,200 @@ +import { GrainIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGenericOutputs, + buildGrainV2ExtraFields, + buildHighlightOutputs, + buildRecordingOutputs, + buildStoryOutputs, + grainV2EventSetupInstructions, + grainV2TriggerOptions, +} from '@/triggers/grain/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const grainRecordingAddedV2Trigger: TriggerConfig = { + id: 'grain_recording_added_v2', + name: 'Grain Recording Added', + provider: 'grain', + description: 'Trigger when a new recording is added in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_recording_added_v2', + triggerOptions: grainV2TriggerOptions, + includeDropdown: true, + setupInstructions: grainV2EventSetupInstructions('Recording Added'), + extraFields: buildGrainV2ExtraFields('grain_recording_added_v2'), + }), + outputs: buildRecordingOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainRecordingUpdatedV2Trigger: TriggerConfig = { + id: 'grain_recording_updated_v2', + name: 'Grain Recording Updated', + provider: 'grain', + description: 'Trigger when a recording is updated in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_recording_updated_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Recording Updated'), + extraFields: buildGrainV2ExtraFields('grain_recording_updated_v2'), + }), + outputs: buildRecordingOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainRecordingDeletedV2Trigger: TriggerConfig = { + id: 'grain_recording_deleted_v2', + name: 'Grain Recording Deleted', + provider: 'grain', + description: 'Trigger when a recording is deleted in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_recording_deleted_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Recording Deleted'), + extraFields: buildGrainV2ExtraFields('grain_recording_deleted_v2'), + }), + outputs: buildGenericOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainHighlightAddedV2Trigger: TriggerConfig = { + id: 'grain_highlight_added_v2', + name: 'Grain Highlight Added', + provider: 'grain', + description: 'Trigger when a new highlight/clip is created in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_highlight_added_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Highlight Added'), + extraFields: buildGrainV2ExtraFields('grain_highlight_added_v2'), + }), + outputs: buildHighlightOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainHighlightUpdatedV2Trigger: TriggerConfig = { + id: 'grain_highlight_updated_v2', + name: 'Grain Highlight Updated', + provider: 'grain', + description: 'Trigger when a highlight/clip is updated in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_highlight_updated_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Highlight Updated'), + extraFields: buildGrainV2ExtraFields('grain_highlight_updated_v2'), + }), + outputs: buildHighlightOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainHighlightDeletedV2Trigger: TriggerConfig = { + id: 'grain_highlight_deleted_v2', + name: 'Grain Highlight Deleted', + provider: 'grain', + description: 'Trigger when a highlight/clip is deleted in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_highlight_deleted_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Highlight Deleted'), + extraFields: buildGrainV2ExtraFields('grain_highlight_deleted_v2'), + }), + outputs: buildGenericOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainStoryAddedV2Trigger: TriggerConfig = { + id: 'grain_story_added_v2', + name: 'Grain Story Added', + provider: 'grain', + description: 'Trigger when a new story is created in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_story_added_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Story Added'), + extraFields: buildGrainV2ExtraFields('grain_story_added_v2'), + }), + outputs: buildStoryOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainStoryUpdatedV2Trigger: TriggerConfig = { + id: 'grain_story_updated_v2', + name: 'Grain Story Updated', + provider: 'grain', + description: 'Trigger when a story is updated in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_story_updated_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Story Updated'), + extraFields: buildGrainV2ExtraFields('grain_story_updated_v2'), + }), + outputs: buildStoryOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainStoryDeletedV2Trigger: TriggerConfig = { + id: 'grain_story_deleted_v2', + name: 'Grain Story Deleted', + provider: 'grain', + description: 'Trigger when a story is deleted in Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_story_deleted_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Story Deleted'), + extraFields: buildGrainV2ExtraFields('grain_story_deleted_v2'), + }), + outputs: buildGenericOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainUploadStatusV2Trigger: TriggerConfig = { + id: 'grain_upload_status_v2', + name: 'Grain Upload Status', + provider: 'grain', + description: 'Trigger on progress updates for recordings uploaded to Grain', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_upload_status_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('Upload Status'), + extraFields: buildGrainV2ExtraFields('grain_upload_status_v2'), + }), + outputs: buildGenericOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} + +export const grainAllEventsV2Trigger: TriggerConfig = { + id: 'grain_all_events_v2', + name: 'Grain All Events', + provider: 'grain', + description: 'Trigger on every Grain event (recordings, highlights, stories, uploads)', + version: '2.0.0', + icon: GrainIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'grain_all_events_v2', + triggerOptions: grainV2TriggerOptions, + setupInstructions: grainV2EventSetupInstructions('All Events'), + extraFields: buildGrainV2ExtraFields('grain_all_events_v2'), + }), + outputs: buildGenericOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} diff --git a/apps/sim/triggers/grain/highlight_created.ts b/apps/sim/triggers/grain/highlight_created.ts index bce057c9a0e..b0ffddbe094 100644 --- a/apps/sim/triggers/grain/highlight_created.ts +++ b/apps/sim/triggers/grain/highlight_created.ts @@ -8,6 +8,7 @@ export const grainHighlightCreatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger workflow when a new highlight/clip is created in Grain', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/highlight_updated.ts b/apps/sim/triggers/grain/highlight_updated.ts index f9e3a899687..eee23b1c2d8 100644 --- a/apps/sim/triggers/grain/highlight_updated.ts +++ b/apps/sim/triggers/grain/highlight_updated.ts @@ -8,6 +8,7 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger workflow when a highlight/clip is updated in Grain', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/index.ts b/apps/sim/triggers/grain/index.ts index 4b219ca1132..460ad20db0c 100644 --- a/apps/sim/triggers/grain/index.ts +++ b/apps/sim/triggers/grain/index.ts @@ -1,3 +1,16 @@ +export { + grainAllEventsV2Trigger, + grainHighlightAddedV2Trigger, + grainHighlightDeletedV2Trigger, + grainHighlightUpdatedV2Trigger, + grainRecordingAddedV2Trigger, + grainRecordingDeletedV2Trigger, + grainRecordingUpdatedV2Trigger, + grainStoryAddedV2Trigger, + grainStoryDeletedV2Trigger, + grainStoryUpdatedV2Trigger, + grainUploadStatusV2Trigger, +} from './events_v2' export { grainHighlightCreatedTrigger } from './highlight_created' export { grainHighlightUpdatedTrigger } from './highlight_updated' export { grainItemAddedTrigger } from './item_added' diff --git a/apps/sim/triggers/grain/item_added.ts b/apps/sim/triggers/grain/item_added.ts index 76bd4ba872c..86aed8928f3 100644 --- a/apps/sim/triggers/grain/item_added.ts +++ b/apps/sim/triggers/grain/item_added.ts @@ -8,6 +8,7 @@ export const grainItemAddedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger when a new item is added to a Grain view (recording, highlight, or story)', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/item_updated.ts b/apps/sim/triggers/grain/item_updated.ts index b06706ad696..b80f24a36ec 100644 --- a/apps/sim/triggers/grain/item_updated.ts +++ b/apps/sim/triggers/grain/item_updated.ts @@ -8,6 +8,7 @@ export const grainItemUpdatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger when an item is updated in a Grain view (recording, highlight, or story)', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/recording_created.ts b/apps/sim/triggers/grain/recording_created.ts index 83a43b85d53..44341cae862 100644 --- a/apps/sim/triggers/grain/recording_created.ts +++ b/apps/sim/triggers/grain/recording_created.ts @@ -8,6 +8,7 @@ export const grainRecordingCreatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger workflow when a new recording is added in Grain', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/recording_updated.ts b/apps/sim/triggers/grain/recording_updated.ts index 4b402418a87..9debdbd2ca7 100644 --- a/apps/sim/triggers/grain/recording_updated.ts +++ b/apps/sim/triggers/grain/recording_updated.ts @@ -8,6 +8,7 @@ export const grainRecordingUpdatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger workflow when a recording is updated in Grain', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/story_created.ts b/apps/sim/triggers/grain/story_created.ts index d50d3415059..a104485d2c8 100644 --- a/apps/sim/triggers/grain/story_created.ts +++ b/apps/sim/triggers/grain/story_created.ts @@ -8,6 +8,7 @@ export const grainStoryCreatedTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger workflow when a new story is created in Grain', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/grain/utils.ts b/apps/sim/triggers/grain/utils.ts index 3f3613d0a9b..9a857c9a9cc 100644 --- a/apps/sim/triggers/grain/utils.ts +++ b/apps/sim/triggers/grain/utils.ts @@ -1,5 +1,107 @@ +import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' +/** + * Hook types each v2 trigger subscribes to on the Grain v2 hooks API. One + * external hook is created per hook type (v2 has no multi-event hooks), so the + * All Events trigger owns one hook per type. + */ +export const GRAIN_V2_TRIGGER_TO_HOOK_TYPES = { + grain_recording_added_v2: ['recording_added'], + grain_recording_updated_v2: ['recording_updated'], + grain_recording_deleted_v2: ['recording_deleted'], + grain_highlight_added_v2: ['highlight_added'], + grain_highlight_updated_v2: ['highlight_updated'], + grain_highlight_deleted_v2: ['highlight_deleted'], + grain_story_added_v2: ['story_added'], + grain_story_updated_v2: ['story_updated'], + grain_story_deleted_v2: ['story_deleted'], + grain_upload_status_v2: ['upload_status'], + grain_all_events_v2: [ + 'recording_added', + 'recording_updated', + 'recording_deleted', + 'highlight_added', + 'highlight_updated', + 'highlight_deleted', + 'story_added', + 'story_updated', + 'story_deleted', + 'upload_status', + ], +} as const + +export const grainV2TriggerOptions = [ + { label: 'Recording Added', id: 'grain_recording_added_v2' }, + { label: 'Recording Updated', id: 'grain_recording_updated_v2' }, + { label: 'Recording Deleted', id: 'grain_recording_deleted_v2' }, + { label: 'Highlight Added', id: 'grain_highlight_added_v2' }, + { label: 'Highlight Updated', id: 'grain_highlight_updated_v2' }, + { label: 'Highlight Deleted', id: 'grain_highlight_deleted_v2' }, + { label: 'Story Added', id: 'grain_story_added_v2' }, + { label: 'Story Updated', id: 'grain_story_updated_v2' }, + { label: 'Story Deleted', id: 'grain_story_deleted_v2' }, + { label: 'Upload Status', id: 'grain_upload_status_v2' }, + { label: 'All Events', id: 'grain_all_events_v2' }, +] + +/** Hook type options for the Create Webhook operation dropdown. */ +export const GRAIN_HOOK_TYPE_OPTIONS = [ + { label: 'Recording Added', id: 'recording_added' }, + { label: 'Recording Updated', id: 'recording_updated' }, + { label: 'Recording Deleted', id: 'recording_deleted' }, + { label: 'Highlight Added', id: 'highlight_added' }, + { label: 'Highlight Updated', id: 'highlight_updated' }, + { label: 'Highlight Deleted', id: 'highlight_deleted' }, + { label: 'Story Added', id: 'story_added' }, + { label: 'Story Updated', id: 'story_updated' }, + { label: 'Story Deleted', id: 'story_deleted' }, + { label: 'Upload Status', id: 'upload_status' }, +] + +/** + * Setup instructions for the v2 event triggers. + */ +export function grainV2EventSetupInstructions(eventLabel: string): string { + const webhookSentence = + eventLabel === 'All Events' + ? 'Grain creates one webhook per event type when you deploy, and deletes them when this trigger is removed.' + : `Grain creates a ${eventLabel} webhook when you deploy, and deletes it when this trigger is removed.` + + const instructions = [ + 'Enter your Grain API Key (Personal or Workspace Access Token). You can find or create one in Grain at Workspace Settings > API under Integrations on grain.com.', + webhookSentence, + 'Place additional Grain trigger blocks to react to other event types.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Shared credential field for the v2 event triggers. + */ +export function buildGrainV2ExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Grain API key', + description: 'Required to create the webhook in Grain.', + password: true, + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + /** * Trigger dropdown options for Grain triggers. * New options (Item Added / Item Updated / All Events) correctly scope by view_id only. diff --git a/apps/sim/triggers/grain/webhook.ts b/apps/sim/triggers/grain/webhook.ts index 25ee70c0b12..39db7a3ee1f 100644 --- a/apps/sim/triggers/grain/webhook.ts +++ b/apps/sim/triggers/grain/webhook.ts @@ -8,6 +8,7 @@ export const grainWebhookTrigger: TriggerConfig = { provider: 'grain', description: 'Trigger on all actions (added, updated, removed) in a Grain view', version: '1.0.0', + deprecated: true, icon: GrainIcon, subBlocks: [ diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index 75a58dbe569..a05e17068c0 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -61,6 +61,7 @@ export const hubspotPollingTrigger: TriggerConfig = { description: 'Connect a HubSpot account so Sim can poll your CRM on your behalf.', serviceId: 'hubspot', requiredScopes: getScopesForService('hubspot'), + allowServiceAccounts: true, required: true, mode: 'trigger', }, diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index a7a31cef8f5..720e16884a3 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -74,6 +74,37 @@ import { clerkUserUpdatedTrigger, clerkWebhookTrigger, } from '@/triggers/clerk' +import { + clickupFolderCreatedTrigger, + clickupFolderDeletedTrigger, + clickupFolderUpdatedTrigger, + clickupGoalCreatedTrigger, + clickupGoalDeletedTrigger, + clickupGoalUpdatedTrigger, + clickupKeyResultCreatedTrigger, + clickupKeyResultDeletedTrigger, + clickupKeyResultUpdatedTrigger, + clickupListCreatedTrigger, + clickupListDeletedTrigger, + clickupListUpdatedTrigger, + clickupSpaceCreatedTrigger, + clickupSpaceDeletedTrigger, + clickupSpaceUpdatedTrigger, + clickupTaskAssigneeUpdatedTrigger, + clickupTaskCommentPostedTrigger, + clickupTaskCommentUpdatedTrigger, + clickupTaskCreatedTrigger, + clickupTaskDeletedTrigger, + clickupTaskDueDateUpdatedTrigger, + clickupTaskMovedTrigger, + clickupTaskPriorityUpdatedTrigger, + clickupTaskStatusUpdatedTrigger, + clickupTaskTagUpdatedTrigger, + clickupTaskTimeEstimateUpdatedTrigger, + clickupTaskTimeTrackedUpdatedTrigger, + clickupTaskUpdatedTrigger, + clickupWebhookTrigger, +} from '@/triggers/clickup' import { confluenceAttachmentCreatedTrigger, confluenceAttachmentRemovedTrigger, @@ -150,13 +181,24 @@ import { googleDrivePollingTrigger } from '@/triggers/google-drive' import { googleSheetsPollingTrigger } from '@/triggers/google-sheets' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' import { + grainAllEventsV2Trigger, + grainHighlightAddedV2Trigger, grainHighlightCreatedTrigger, + grainHighlightDeletedV2Trigger, grainHighlightUpdatedTrigger, + grainHighlightUpdatedV2Trigger, grainItemAddedTrigger, grainItemUpdatedTrigger, + grainRecordingAddedV2Trigger, grainRecordingCreatedTrigger, + grainRecordingDeletedV2Trigger, grainRecordingUpdatedTrigger, + grainRecordingUpdatedV2Trigger, + grainStoryAddedV2Trigger, grainStoryCreatedTrigger, + grainStoryDeletedV2Trigger, + grainStoryUpdatedV2Trigger, + grainUploadStatusV2Trigger, grainWebhookTrigger, } from '@/triggers/grain' import { @@ -478,6 +520,35 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { calcom_meeting_ended: calcomMeetingEndedTrigger, calcom_recording_ready: calcomRecordingReadyTrigger, calcom_webhook: calcomWebhookTrigger, + clickup_task_created: clickupTaskCreatedTrigger, + clickup_task_updated: clickupTaskUpdatedTrigger, + clickup_task_deleted: clickupTaskDeletedTrigger, + clickup_task_status_updated: clickupTaskStatusUpdatedTrigger, + clickup_task_priority_updated: clickupTaskPriorityUpdatedTrigger, + clickup_task_assignee_updated: clickupTaskAssigneeUpdatedTrigger, + clickup_task_due_date_updated: clickupTaskDueDateUpdatedTrigger, + clickup_task_tag_updated: clickupTaskTagUpdatedTrigger, + clickup_task_moved: clickupTaskMovedTrigger, + clickup_task_comment_posted: clickupTaskCommentPostedTrigger, + clickup_task_comment_updated: clickupTaskCommentUpdatedTrigger, + clickup_task_time_estimate_updated: clickupTaskTimeEstimateUpdatedTrigger, + clickup_task_time_tracked_updated: clickupTaskTimeTrackedUpdatedTrigger, + clickup_list_created: clickupListCreatedTrigger, + clickup_list_updated: clickupListUpdatedTrigger, + clickup_list_deleted: clickupListDeletedTrigger, + clickup_folder_created: clickupFolderCreatedTrigger, + clickup_folder_updated: clickupFolderUpdatedTrigger, + clickup_folder_deleted: clickupFolderDeletedTrigger, + clickup_space_created: clickupSpaceCreatedTrigger, + clickup_space_updated: clickupSpaceUpdatedTrigger, + clickup_space_deleted: clickupSpaceDeletedTrigger, + clickup_goal_created: clickupGoalCreatedTrigger, + clickup_goal_updated: clickupGoalUpdatedTrigger, + clickup_goal_deleted: clickupGoalDeletedTrigger, + clickup_key_result_created: clickupKeyResultCreatedTrigger, + clickup_key_result_updated: clickupKeyResultUpdatedTrigger, + clickup_key_result_deleted: clickupKeyResultDeletedTrigger, + clickup_webhook: clickupWebhookTrigger, confluence_webhook: confluenceWebhookTrigger, confluence_page_created: confluencePageCreatedTrigger, confluence_page_updated: confluencePageUpdatedTrigger, @@ -554,6 +625,17 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { google_sheets_poller: googleSheetsPollingTrigger, gong_call_completed: gongCallCompletedTrigger, gong_webhook: gongWebhookTrigger, + grain_recording_added_v2: grainRecordingAddedV2Trigger, + grain_recording_updated_v2: grainRecordingUpdatedV2Trigger, + grain_recording_deleted_v2: grainRecordingDeletedV2Trigger, + grain_highlight_added_v2: grainHighlightAddedV2Trigger, + grain_highlight_updated_v2: grainHighlightUpdatedV2Trigger, + grain_highlight_deleted_v2: grainHighlightDeletedV2Trigger, + grain_story_added_v2: grainStoryAddedV2Trigger, + grain_story_updated_v2: grainStoryUpdatedV2Trigger, + grain_story_deleted_v2: grainStoryDeletedV2Trigger, + grain_upload_status_v2: grainUploadStatusV2Trigger, + grain_all_events_v2: grainAllEventsV2Trigger, grain_webhook: grainWebhookTrigger, grain_item_added: grainItemAddedTrigger, grain_item_updated: grainItemUpdatedTrigger, diff --git a/apps/sim/triggers/types.ts b/apps/sim/triggers/types.ts index eb9277484e5..1e9d97614fc 100644 --- a/apps/sim/triggers/types.ts +++ b/apps/sim/triggers/types.ts @@ -30,6 +30,14 @@ export interface TriggerConfig { /** When true, this trigger is poll-based (cron-driven) rather than push-based. */ polling?: boolean + + /** + * When true, the trigger stays registered so existing workflows keep + * resolving, but it is excluded from generated documentation. Used for + * triggers superseded by a newer version (e.g. Grain view-scoped triggers + * after the v1 API sunset). + */ + deprecated?: boolean } export interface TriggerRegistry { diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index ca6b4fc3922..1213b48efc8 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -87,8 +87,19 @@ vi.mock('@/blocks/registry', () => ({ subBlocks: [], outputs: {}, })), - getAllBlocks: vi.fn(() => ({})), + getAllBlocks: vi.fn(() => []), getLatestBlock: vi.fn(() => undefined), + getBlockByToolName: vi.fn((toolName: string) => + toolName.startsWith('gmail_') + ? { + name: 'Gmail', + description: 'Gmail integration', + icon: () => null, + subBlocks: [], + outputs: {}, + } + : undefined + ), })) vi.mock('@trigger.dev/sdk', () => ({ diff --git a/bun.lock b/bun.lock index eaee1d6f50f..7e81dd4365f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -135,6 +136,7 @@ "@e2b/code-interpreter": "^2.0.0", "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", + "@google-cloud/storage": "7.21.0", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", "@linear/sdk": "40.0.0", @@ -1034,8 +1036,16 @@ "@fumari/stf": ["@fumari/stf@1.0.5", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-O9UQIbV15ePV5vUgceCcaMDuBRYfrSuogDEY5E//CmrbIiWJ1Ji5VgGHHH0L0HQuRr5riUJuAEr9lYIvJl9OyQ=="], + "@google-cloud/paginator": ["@google-cloud/paginator@5.0.2", "", { "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" } }, "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg=="], + "@google-cloud/precise-date": ["@google-cloud/precise-date@4.0.0", "", {}, "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA=="], + "@google-cloud/projectify": ["@google-cloud/projectify@4.0.0", "", {}, "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA=="], + + "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], + + "@google-cloud/storage": ["@google-cloud/storage@7.21.0", "", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0" } }, "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA=="], + "@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -1804,6 +1814,8 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], + "@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="], "@trigger.dev/core": ["@trigger.dev/core@4.4.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/exporter-metrics-otlp-http": "0.203.0", "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@opentelemetry/host-metrics": "^0.37.0", "@opentelemetry/instrumentation": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "0.22.5", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-4srm2UGoDEcHO29Lqp4Isioq+b6au0EjW9/pjYmzOSxXqGPFDjPquK0BnKYGHyAbKYxuBx8wr2T/ru+zbY0/Jg=="], @@ -1838,6 +1850,8 @@ "@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="], + "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cookie": ["@types/cookie@0.4.1", "", {}, "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="], @@ -1952,6 +1966,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], @@ -2118,6 +2134,8 @@ "arktype": ["arktype@2.2.0", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ=="], + "arrify": ["arrify@2.0.1", "", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -2128,6 +2146,8 @@ "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], + "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -2506,6 +2526,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "e2b": ["e2b@2.30.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-4kvfwh3QfPukrYmWEhrVLxL3WnQabzHabvhIRmvk6oU/YTWQtCrlZX+jaA9XBtVI/vQUbu5E5a6GlOhDXmcKzg=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -2700,7 +2722,7 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], "gcp-metadata": ["gcp-metadata@8.1.3", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w=="], @@ -2800,6 +2822,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], @@ -2902,7 +2926,7 @@ "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], - "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -3062,7 +3086,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -3208,6 +3232,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -3362,6 +3388,8 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -3650,6 +3678,8 @@ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -3808,6 +3838,10 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], + + "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], + "streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="], "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], @@ -3844,6 +3878,8 @@ "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -3882,6 +3918,8 @@ "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], + "teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -4104,6 +4142,8 @@ "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="], @@ -4188,6 +4228,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -4420,10 +4462,6 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], - "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - - "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4496,6 +4534,10 @@ "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/request/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + + "@types/request/form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="], + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@types/through/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4510,6 +4552,8 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4572,6 +4616,8 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], @@ -4584,6 +4630,8 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -4612,22 +4660,34 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "groq-sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "gtoken/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], @@ -4762,8 +4822,6 @@ "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -4798,6 +4856,14 @@ "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + + "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "teeny-request/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tsconfck/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -4890,6 +4956,10 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@google-cloud/storage/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], + + "@google-cloud/storage/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], + "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -4976,6 +5046,10 @@ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/through/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5012,6 +5086,8 @@ "docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "duplexify/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -5072,6 +5148,8 @@ "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "gaxios/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "groq-sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -5202,6 +5280,12 @@ "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "teeny-request/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], @@ -5272,6 +5356,8 @@ "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], @@ -5292,8 +5378,14 @@ "@trigger.dev/core/socket.io/engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "gaxios/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "gaxios/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "groq-sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "groq-sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -5350,6 +5442,10 @@ "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "teeny-request/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "teeny-request/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/docker/pii.Dockerfile b/docker/pii.Dockerfile index 205462bad3f..4c688ac6e55 100644 --- a/docker/pii.Dockerfile +++ b/docker/pii.Dockerfile @@ -1,15 +1,6 @@ # ======================================== -# Combined Presidio service (analyzer + anonymizer) on a single port (5001) -# -# ONE image serves both NER engines — the engine is a pure runtime choice via -# PII_ENGINE (spacy default | gliner). spaCy large models, torch (CPU), the -# gliner package, and the baked GLiNER weights all ship in it, so flipping -# engines never requires an image swap. -# -# ONE image also serves both fleets: the amd64 build ships CUDA torch, which -# falls back to CPU when no GPU is present, so the Fargate CPU tasks and the -# EC2-GPU tasks pull the same tag. (torch CUDA wheels bundle their own CUDA -# libs; the host only needs the nvidia driver + container runtime.) +# Combined Presidio service (analyzer + anonymizer) on a single port (5001). +# CPU spaCy NER + regex/checksum pattern recognizers. # # Source files are COPY'd last so code edits never re-download deps or models. # ======================================== @@ -43,78 +34,11 @@ RUN --mount=type=cache,target=/root/.cache/pip \ pip install /tmp/*.whl && \ rm /tmp/*.whl -# --- GLiNER engine deps ------------------------------------------------------- -# torch is pinned here (not requirements-gliner.txt) because the CPU and CUDA -# builds install the same version from different wheel indexes. 2.11.0 is the -# newest release published on both the cpu and cu128 indexes for py312. -# -# cu128's arch list keeps sm_75, the compute capability of the GPU fleet's T4s. -# cu121 could not serve this pin anyway — that index stops at torch 2.5.1. -# CUDA 12.8 needs an NVIDIA driver >=525 via minor-version compatibility, which -# the ECS GPU AMI's nvidia-driver-latest-dkms satisfies. -# -# arm64 takes the cpu index: cu128 publishes no aarch64 wheel at 2.11.0, and no -# arm64 target has a GPU. -ARG TORCH_VERSION=2.11.0 -ARG TORCH_CUDA_INDEX_URL=https://download.pytorch.org/whl/cu128 -ARG TORCH_CPU_INDEX_URL=https://download.pytorch.org/whl/cpu -ARG TARGETARCH -RUN --mount=type=cache,target=/root/.cache/pip \ - case "${TARGETARCH}" in \ - amd64) torch_index="${TORCH_CUDA_INDEX_URL}" ;; \ - arm64) torch_index="${TORCH_CPU_INDEX_URL}" ;; \ - *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ - esac && \ - pip install torch==${TORCH_VERSION} --index-url "${torch_index}" - -COPY apps/pii/requirements-gliner.txt ./requirements-gliner.txt -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install -r requirements-gliner.txt - -# Small spaCy models (~60MB total) give the gliner engine tokenization + -# lemmas for the regex recognizers; GLiNER does the NER (see engines.py). -ARG SPACY_SM_MODELS="en_core_web_sm-3.8.0 es_core_news_sm-3.8.0 it_core_news_sm-3.8.0 pl_core_news_sm-3.8.0 fi_core_news_sm-3.8.0" -RUN --mount=type=cache,target=/root/.cache/pip \ - for model in ${SPACY_SM_MODELS}; do \ - whl="${model}-py3-none-any.whl"; \ - curl -fL --retry 5 --retry-delay 5 --retry-all-errors -C - \ - -o "/tmp/${whl}" \ - "https://github.com/explosion/spacy-models/releases/download/${model}/${whl}" || exit 1; \ - done && \ - pip install /tmp/*.whl && \ - rm /tmp/*.whl - -# Bake the GLiNER weights at build time (cached layer) so startup never -# touches the network. HF_HUB_OFFLINE makes a missing/overridden -# PII_GLINER_MODEL fail fast at startup instead of silently downloading. -ENV HF_HOME=/opt/hf-cache -ARG GLINER_MODEL=urchade/gliner_multi_pii-v1 -RUN python -c "from gliner import GLiNER; GLiNER.from_pretrained('${GLINER_MODEL}')" && \ - chmod -R a+rX /opt/hf-cache -ENV HF_HUB_OFFLINE=1 - -# pytest/httpx for the in-image test suites (tests/) — baked in because the -# runtime user has no writable HOME for pip install --user. -COPY apps/pii/requirements-dev.txt ./requirements-dev.txt -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install -r requirements-dev.txt - -# Runs after every pip install, because the requirements above resolve against -# PyPI and could swap the wheel torch_index chose. A cpu-only torch on amd64 -# otherwise surfaces only as "torch.cuda.is_available() is False" once GLiNER -# loads on a GPU host. -RUN python -c "import torch; \ -have = torch.version.cuda is not None; \ -want = '${TARGETARCH}' == 'amd64'; \ -assert have == want, f'{torch.__version__}: cuda build={have}, expected={want}'" - RUN groupadd -g 1001 pii && \ useradd -u 1001 -g pii pii && \ chown -R pii:pii /app -COPY --chown=pii:pii apps/pii/server.py apps/pii/engines.py ./ -COPY --chown=pii:pii apps/pii/scripts ./scripts -COPY --chown=pii:pii apps/pii/tests ./tests +COPY --chown=pii:pii apps/pii/server.py ./ USER pii @@ -134,6 +58,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \ # `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM. # Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather # than being interpreted by the shell. -# NB for the gliner engine: EACH worker loads its own GLiNER model copy (into GPU -# memory when on cuda), so GPU deployments generally want PII_WORKERS=1 per GPU. CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""] diff --git a/helm/sim/examples/values-gcp.yaml b/helm/sim/examples/values-gcp.yaml index 3356ac99894..4a3a27f9762 100644 --- a/helm/sim/examples/values-gcp.yaml +++ b/helm/sim/examples/values-gcp.yaml @@ -90,6 +90,22 @@ app: GOOGLE_CLOUD_PROJECT: "your-project-id" GOOGLE_CLOUD_REGION: "us-central1" + # Google Cloud Storage Configuration (RECOMMENDED for production) + # Create GCS buckets in your project and grant the Workload Identity + # service account roles/storage.objectAdmin on them. Signed-URL generation + # without a key file additionally requires roles/iam.serviceAccountTokenCreator + # on the service account itself (or set GCS_CREDENTIALS_JSON to inline + # service-account JSON with a private key). + GCS_PROJECT_ID: "your-project-id" + GCS_BUCKET_NAME: "myorg-sim-workspace-files" # Workspace files (enables GCS) + GCS_KB_BUCKET_NAME: "myorg-sim-knowledge-base" # Knowledge base documents + GCS_EXECUTION_FILES_BUCKET_NAME: "myorg-sim-execution-files" # Workflow execution outputs + GCS_CHAT_BUCKET_NAME: "myorg-sim-chat-files" # Deployed chat assets + GCS_COPILOT_BUCKET_NAME: "myorg-sim-copilot-files" # Copilot attachments + GCS_PROFILE_PICTURES_BUCKET_NAME: "myorg-sim-profile-pictures" # User avatars + GCS_OG_IMAGES_BUCKET_NAME: "myorg-sim-og-images" # OpenGraph preview images + GCS_WORKSPACE_LOGOS_BUCKET_NAME: "myorg-sim-workspace-logos" # Workspace logos + # Realtime service realtime: enabled: true diff --git a/helm/sim/templates/deployment-pii.yaml b/helm/sim/templates/deployment-pii.yaml index 2dd4b8b22c6..033d87725a1 100644 --- a/helm/sim/templates/deployment-pii.yaml +++ b/helm/sim/templates/deployment-pii.yaml @@ -45,13 +45,7 @@ spec: containerPort: {{ .Values.pii.service.targetPort }} protocol: TCP env: - - name: PII_ENGINE - value: {{ .Values.pii.engine | quote }} - {{- if .Values.pii.device }} - - name: PII_DEVICE - value: {{ .Values.pii.device | quote }} - {{- end }} - {{- range $key, $value := omit (.Values.pii.env | default dict) "PII_ENGINE" "PII_DEVICE" }} + {{- range $key, $value := .Values.pii.env | default dict }} - name: {{ $key }} value: {{ $value | quote }} {{- end }} diff --git a/helm/sim/tests/pii_test.yaml b/helm/sim/tests/pii_test.yaml index f35c87a5759..8c03f193c30 100644 --- a/helm/sim/tests/pii_test.yaml +++ b/helm/sim/tests/pii_test.yaml @@ -30,64 +30,18 @@ tests: path: spec.template.spec.containers[0].ports[0].containerPort value: 5001 - - it: pii pod defaults to the spacy engine - template: deployment-pii.yaml - set: - pii.enabled: true - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PII_ENGINE - value: "spacy" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: PII_DEVICE - value: "cpu" - - - it: pii.engine and pii.device render through to the container env - template: deployment-pii.yaml - set: - pii.enabled: true - pii.engine: gliner - pii.device: cuda - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PII_ENGINE - value: "gliner" - - contains: - path: spec.template.spec.containers[0].env - content: - name: PII_DEVICE - value: "cuda" - - - it: user-set pii.env cannot override the chart-owned engine keys + - it: pii.env renders through to the container env template: deployment-pii.yaml set: pii.enabled: true pii.env: - PII_ENGINE: evil - PII_DEVICE: evil - PII_GLINER_MODEL: acme/custom-model + PII_WORKERS: "4" asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: PII_ENGINE - value: "evil" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: PII_DEVICE - value: "evil" - contains: path: spec.template.spec.containers[0].env content: - name: PII_GLINER_MODEL - value: "acme/custom-model" + name: PII_WORKERS + value: "4" - it: app pod gets chart-computed PII_URL pointing at the in-cluster service template: deployment-app.yaml diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 60bdf101dcb..9ba8ac8f657 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -759,15 +759,6 @@ "type": "boolean", "description": "Enable the Presidio PII redaction service" }, - "engine": { - "type": "string", - "enum": ["spacy", "gliner"], - "description": "NER engine (spacy default, gliner opt-in; both ship in the image)" - }, - "device": { - "type": "string", - "description": "Torch device for the gliner engine (cpu, cuda, cuda:N); empty auto-detects" - }, "replicaCount": { "type": "integer", "minimum": 1, diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 8361b45e034..019c8a4e9d4 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -288,6 +288,20 @@ app: AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: "" # Azure container for OpenGraph preview images AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME: "" # Azure container for workspace logos + # Google Cloud Storage Configuration (optional - for file storage) + # If configured, files will be stored in GCS instead of local storage + # Note: used when neither Azure Blob nor S3 is configured + GCS_PROJECT_ID: "" # GCP project ID (optional — inferred from credentials/ADC when unset) + GCS_CREDENTIALS_JSON: "" # Inline service-account JSON. Leave empty to use Workload Identity / Application Default Credentials + GCS_BUCKET_NAME: "" # GCS bucket for workspace files (all other GCS buckets fall back to it) + GCS_KB_BUCKET_NAME: "" # GCS bucket for knowledge base files + GCS_EXECUTION_FILES_BUCKET_NAME: "" # GCS bucket for workflow execution files + GCS_CHAT_BUCKET_NAME: "" # GCS bucket for deployed chat files + GCS_COPILOT_BUCKET_NAME: "" # GCS bucket for copilot files + GCS_PROFILE_PICTURES_BUCKET_NAME: "" # GCS bucket for user profile pictures + GCS_OG_IMAGES_BUCKET_NAME: "" # GCS bucket for OpenGraph preview images + GCS_WORKSPACE_LOGOS_BUCKET_NAME: "" # GCS bucket for workspace logos + # Operational tunables shipped with the chart. These are rendered as inline `env:` on the # app container — NOT written into the chart-managed Secret and NOT required to be mapped # when externalSecrets.enabled=true. Override any key by setting `app.envDefaults.KEY` in @@ -836,18 +850,6 @@ pii: digest: "" # sha256: pin overrides tag pullPolicy: IfNotPresent - # NER engine: "spacy" (default) or "gliner" (opt-in zero-shot transformer NER - # for PERSON/LOCATION/NRP/DATE_TIME; regex/checksum recognizers are identical - # on both engines). The image ships both engines, so this is a pure env flip - # — no image change needed. Note: gliner on CPU is orders of magnitude slower - # than spacy; it is intended for GPU nodes. - engine: "spacy" - - # Torch device for the gliner engine: "cpu", "cuda", or "cuda:N". Empty - # auto-detects (cuda when a GPU is visible, else cpu). GPU resource requests - # (nvidia.com/gpu) are not wired yet — GPU scheduling is an infra follow-up. - device: "" - # Number of replicas replicaCount: 1 diff --git a/packages/audit/src/index.ts b/packages/audit/src/index.ts index f3ec73f8c5d..2813a231de4 100644 --- a/packages/audit/src/index.ts +++ b/packages/audit/src/index.ts @@ -1,3 +1,3 @@ -export { recordAudit } from './log' +export { recordAudit, recordAuditBatch } from './log' export type { AuditActionType, AuditResourceTypeValue } from './types' export { AuditAction, AuditResourceType } from './types' diff --git a/packages/audit/src/log.test.ts b/packages/audit/src/log.test.ts index e47485ee085..98a71773e65 100644 --- a/packages/audit/src/log.test.ts +++ b/packages/audit/src/log.test.ts @@ -37,7 +37,7 @@ vi.mock('@sim/utils/id', () => ({ })) import { sleep } from '@sim/utils/helpers' -import { AuditAction, AuditResourceType, recordAudit } from './index' +import { AuditAction, AuditResourceType, recordAudit, recordAuditBatch } from './index' const flush = () => sleep(10) @@ -385,6 +385,68 @@ describe('recordAudit', () => { }) }) +describe('recordAuditBatch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('writes all entries in a single insert', async () => { + recordAuditBatch([ + { + workspaceId: 'ws-1', + actorId: null, + actorName: 'Billing System', + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: 'ws-1', + }, + { + workspaceId: 'ws-2', + actorId: null, + actorName: 'Billing System', + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: 'ws-2', + }, + ]) + + await flush() + + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ workspaceId: 'ws-1', actorId: null, actorName: 'Billing System' }), + expect.objectContaining({ workspaceId: 'ws-2', actorId: null, actorName: 'Billing System' }), + ]) + }) + + it('does nothing for an empty batch', async () => { + recordAuditBatch([]) + + await flush() + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('does not throw when the batch insert fails', async () => { + dbChainMockFns.values.mockImplementation(() => Promise.reject(new Error('DB connection lost'))) + + expect(() => { + recordAuditBatch([ + { + workspaceId: 'ws-1', + actorId: null, + actorName: 'Billing System', + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + }, + ]) + }).not.toThrow() + + await flush() + }) +}) + describe('auditMock sync', () => { it('has the same AuditAction keys as the source', () => { const sourceKeys = Object.keys(AuditAction).sort() diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 3a9a6ef66d4..93381ae43b7 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -44,10 +44,73 @@ export function recordAudit(params: AuditLogParams): void { }) } -async function insertAuditLog(params: AuditLogParams): Promise { - const ipAddress = params.request ? getClientIp(params.request) : undefined - const userAgent = params.request?.headers.get('user-agent') ?? undefined +/** + * Rows per INSERT statement. Keeps each statement's bind-parameter count + * far below Postgres's 65k limit while still writing large batches in a + * handful of round-trips. + */ +const AUDIT_BATCH_CHUNK_SIZE = 500 + +/** + * Fire-and-forget batch audit write: one INSERT per chunk instead of one + * pooled query per entry, so callers auditing many resources at once (e.g. + * a bulk workspace detach) do not fan out N concurrent pool checkouts. + * + * Unlike {@link recordAudit} there is no lazy actor resolution — entries + * are inserted exactly as provided. Callers must pass an `actorId` that is + * a real `user.id` or `null`, and should supply `actorName`/`actorEmail` + * labels for system actors. + */ +export function recordAuditBatch(entries: AuditLogParams[]): void { + insertAuditLogBatch(entries).catch((error) => { + logger.error('Failed to record audit log batch', { error, count: entries.length }) + }) +} +/** + * Build the `audit_log` row for an entry. Shared by the single and batch + * insert paths so the write shape cannot drift between them. Actor fields + * are taken as-is — lazy actor resolution is layered on top by + * {@link recordAudit} only. + */ +function buildAuditRow( + params: AuditLogParams, + actor: { actorId: string | null; actorName?: string | null; actorEmail?: string | null } +) { + return { + id: generateShortId(), + workspaceId: params.workspaceId || null, + actorId: actor.actorId, + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + actorName: actor.actorName ?? undefined, + actorEmail: actor.actorEmail ?? undefined, + resourceName: params.resourceName, + description: params.description, + metadata: params.metadata ?? {}, + ipAddress: params.request ? getClientIp(params.request) : undefined, + userAgent: params.request?.headers.get('user-agent') ?? undefined, + } +} + +async function insertAuditLogBatch(entries: AuditLogParams[]): Promise { + if (entries.length === 0) return + + const rows = entries.map((params) => + buildAuditRow(params, { + actorId: params.actorId, + actorName: params.actorName, + actorEmail: params.actorEmail, + }) + ) + + for (let index = 0; index < rows.length; index += AUDIT_BATCH_CHUNK_SIZE) { + await db.insert(auditLog).values(rows.slice(index, index + AUDIT_BATCH_CHUNK_SIZE)) + } +} + +async function insertAuditLog(params: AuditLogParams): Promise { let { actorName, actorEmail } = params /** @@ -81,19 +144,5 @@ async function insertAuditLog(params: AuditLogParams): Promise { } } - await db.insert(auditLog).values({ - id: generateShortId(), - workspaceId: params.workspaceId || null, - actorId, - action: params.action, - resourceType: params.resourceType, - resourceId: params.resourceId, - actorName: actorName ?? undefined, - actorEmail: actorEmail ?? undefined, - resourceName: params.resourceName, - description: params.description, - metadata: params.metadata ?? {}, - ipAddress, - userAgent, - }) + await db.insert(auditLog).values(buildAuditRow(params, { actorId, actorName, actorEmail })) } diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 22c2a363a31..a8d1679adb6 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -185,6 +185,8 @@ export const AuditAction = { WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', WORKFLOW_LOCKED: 'workflow.locked', WORKFLOW_UNLOCKED: 'workflow.unlocked', + WORKFLOW_FORK_SYNC_EXCLUDED: 'workflow.fork_sync_excluded', + WORKFLOW_FORK_SYNC_INCLUDED: 'workflow.fork_sync_included', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled', WORKFLOW_EXPORTED: 'workflow.exported', diff --git a/packages/db/migrations/0261_tranquil_donald_blake.sql b/packages/db/migrations/0261_tranquil_donald_blake.sql new file mode 100644 index 00000000000..3ca59ff2bca --- /dev/null +++ b/packages/db/migrations/0261_tranquil_donald_blake.sql @@ -0,0 +1,87 @@ +-- Replay-safety: this file ends in CONCURRENTLY index ops below an embedded COMMIT, +-- so a failure there leaves the migration unjournaled and replays the whole file +-- from the top — every statement here is idempotent (IF NOT EXISTS / duplicate_object), +-- and each CONCURRENTLY build drops any INVALID leftover before rebuilding. +CREATE TABLE IF NOT EXISTS "webhook_path_claim" ( + "path" text PRIMARY KEY NOT NULL, + "workflow_id" text NOT NULL, + "generation" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "webhook_path_claim_generation_check" CHECK ("webhook_path_claim"."generation" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "workflow_deployment_operation" ( + "id" text PRIMARY KEY NOT NULL, + "workflow_id" text NOT NULL, + "deployment_version_id" text NOT NULL, + "version" integer NOT NULL, + "previous_active_version_id" text, + "action" text NOT NULL, + "protocol_version" integer NOT NULL, + "generation" integer NOT NULL, + "status" text DEFAULT 'preparing' NOT NULL, + "component_readiness" jsonb DEFAULT '{}'::jsonb NOT NULL, + "error_code" text, + "error_message" text, + "idempotency_key" text, + "request_hash" text NOT NULL, + "actor_id" text NOT NULL, + "completed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "workflow_deployment_operation_action_check" CHECK ("workflow_deployment_operation"."action" IN ('deploy', 'activate')), + CONSTRAINT "workflow_deployment_operation_status_check" CHECK ("workflow_deployment_operation"."status" IN ('preparing', 'activating', 'active', 'failed', 'superseded')), + CONSTRAINT "workflow_deployment_operation_generation_check" CHECK ("workflow_deployment_operation"."generation" > 0), + CONSTRAINT "workflow_deployment_operation_protocol_version_check" CHECK ("workflow_deployment_operation"."protocol_version" > 0) +); +--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN IF NOT EXISTS "registration_status" text;--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN IF NOT EXISTS "registration_generation" integer;--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN IF NOT EXISTS "config_fingerprint" text;--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN IF NOT EXISTS "prepared_at" timestamp;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN IF NOT EXISTS "deployment_operation_id" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "webhook_path_claim" ADD CONSTRAINT "webhook_path_claim_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workflow_deployment_operation" ADD CONSTRAINT "workflow_deployment_operation_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workflow_deployment_operation" ADD CONSTRAINT "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk" FOREIGN KEY ("deployment_version_id") REFERENCES "public"."workflow_deployment_version"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workflow_deployment_operation" ADD CONSTRAINT "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk" FOREIGN KEY ("previous_active_version_id") REFERENCES "public"."workflow_deployment_version"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "webhook_path_claim_workflow_idx" ON "webhook_path_claim" USING btree ("workflow_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "workflow_deployment_operation_workflow_generation_unique" ON "workflow_deployment_operation" USING btree ("workflow_id","generation");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "workflow_deployment_operation_workflow_idempotency_unique" ON "workflow_deployment_operation" USING btree ("workflow_id","idempotency_key") WHERE "workflow_deployment_operation"."idempotency_key" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "workflow_deployment_operation_workflow_in_flight_unique" ON "workflow_deployment_operation" USING btree ("workflow_id") WHERE "workflow_deployment_operation"."status" IN ('preparing', 'activating');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workflow_deployment_operation_workflow_status_idx" ON "workflow_deployment_operation" USING btree ("workflow_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workflow_deployment_operation_deployment_version_idx" ON "workflow_deployment_operation" USING btree ("deployment_version_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workflow_deployment_operation_workflow_version_generation_idx" ON "workflow_deployment_operation" USING btree ("workflow_id","deployment_version_id","generation" DESC NULLS LAST);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk" FOREIGN KEY ("deployment_operation_id") REFERENCES "public"."workflow_deployment_operation"("id") ON DELETE set null ON UPDATE no action NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "webhook" ADD CONSTRAINT "webhook_registration_status_check" CHECK ("webhook"."registration_status" IS NULL OR "webhook"."registration_status" IN ('active', 'candidate', 'retired', 'orphaned')) NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "webhook" ADD CONSTRAINT "webhook_registration_generation_check" CHECK ("webhook"."registration_generation" IS NULL OR "webhook"."registration_generation" >= 0) NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "webhook_active_registration_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "webhook_active_registration_unique" ON "webhook" USING btree ("workflow_id","block_id") WHERE "webhook"."registration_status" = 'active' AND "webhook"."block_id" IS NOT NULL AND "webhook"."archived_at" IS NULL;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "webhook_candidate_registration_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "webhook_candidate_registration_unique" ON "webhook" USING btree ("workflow_id","block_id") WHERE "webhook"."registration_status" = 'candidate' AND "webhook"."block_id" IS NOT NULL;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "webhook_registration_status_generation_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "webhook_registration_status_generation_idx" ON "webhook" USING btree ("workflow_id","registration_status","registration_generation");--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/0262_strong_storm.sql b/packages/db/migrations/0262_strong_storm.sql new file mode 100644 index 00000000000..180d5266afa --- /dev/null +++ b/packages/db/migrations/0262_strong_storm.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "message_id" text; \ No newline at end of file diff --git a/packages/db/migrations/0263_workflow_fork_sync_excluded.sql b/packages/db/migrations/0263_workflow_fork_sync_excluded.sql new file mode 100644 index 00000000000..13df06ab1a5 --- /dev/null +++ b/packages/db/migrations/0263_workflow_fork_sync_excluded.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow" ADD COLUMN "fork_sync_excluded" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0261_snapshot.json b/packages/db/migrations/meta/0261_snapshot.json new file mode 100644 index 00000000000..1f8635acb22 --- /dev/null +++ b/packages/db/migrations/meta/0261_snapshot.json @@ -0,0 +1,17352 @@ +{ + "id": "be74722a-6818-45a9-8310-eb027918b9c4", + "prevId": "d708558f-4e3a-4d38-96e5-ca0d65c83f45", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0262_snapshot.json b/packages/db/migrations/meta/0262_snapshot.json new file mode 100644 index 00000000000..58fbfe74b17 --- /dev/null +++ b/packages/db/migrations/meta/0262_snapshot.json @@ -0,0 +1,17358 @@ +{ + "id": "296b31b9-f1e6-4d67-838c-06801e9818a3", + "prevId": "be74722a-6818-45a9-8310-eb027918b9c4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0263_snapshot.json b/packages/db/migrations/meta/0263_snapshot.json new file mode 100644 index 00000000000..5527af876cd --- /dev/null +++ b/packages/db/migrations/meta/0263_snapshot.json @@ -0,0 +1,17365 @@ +{ + "id": "cca39e95-9fb8-4e3d-9e0d-591aa668edb5", + "prevId": "296b31b9-f1e6-4d67-838c-06801e9818a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1cad973fa4f..8a9a55e7524 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1821,6 +1821,27 @@ "when": 1783810442774, "tag": "0260_unknown_sinister_six", "breakpoints": true + }, + { + "idx": 261, + "version": "7", + "when": 1784043925919, + "tag": "0261_tranquil_donald_blake", + "breakpoints": true + }, + { + "idx": 262, + "version": "7", + "when": 1784224314431, + "tag": "0262_strong_storm", + "breakpoints": true + }, + { + "idx": 263, + "version": "7", + "when": 1784252317428, + "tag": "0263_workflow_fork_sync_excluded", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 089cdf4c997..de97f1f9738 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -168,6 +168,7 @@ export const workflow = pgTable( deployedAt: timestamp('deployed_at'), isPublicApi: boolean('is_public_api').notNull().default(false), locked: boolean('locked').notNull().default(false), + forkSyncExcluded: boolean('fork_sync_excluded').notNull().default(false), runCount: integer('run_count').notNull().default(0), lastRunAt: timestamp('last_run_at'), variables: json('variables').default('{}'), @@ -633,6 +634,10 @@ export const workflowSchedule = pgTable( () => workflowDeploymentVersion.id, { onDelete: 'cascade' } ), + deploymentOperationId: text('deployment_operation_id').references( + (): AnyPgColumn => workflowDeploymentOperation.id, + { onDelete: 'set null' } + ), blockId: text('block_id'), cronExpression: text('cron_expression'), nextRunAt: timestamp('next_run_at'), @@ -748,6 +753,10 @@ export const webhook = pgTable( () => workflowDeploymentVersion.id, { onDelete: 'cascade' } ), + registrationStatus: text('registration_status'), + registrationGeneration: integer('registration_generation'), + configFingerprint: text('config_fingerprint'), + preparedAt: timestamp('prepared_at'), blockId: text('block_id'), /** * URL-addressable webhook path. NULL for shared-app providers (e.g. the @@ -801,10 +810,51 @@ export const webhook = pgTable( table.blockId, table.updatedAt.desc() ), + activeRegistrationUnique: uniqueIndex('webhook_active_registration_unique') + .on(table.workflowId, table.blockId) + .where( + sql`${table.registrationStatus} = 'active' AND ${table.blockId} IS NOT NULL AND ${table.archivedAt} IS NULL` + ), + candidateRegistrationUnique: uniqueIndex('webhook_candidate_registration_unique') + .on(table.workflowId, table.blockId) + .where(sql`${table.registrationStatus} = 'candidate' AND ${table.blockId} IS NOT NULL`), + registrationGenerationIdx: index('webhook_registration_status_generation_idx').on( + table.workflowId, + table.registrationStatus, + table.registrationGeneration + ), + registrationStatusCheck: check( + 'webhook_registration_status_check', + sql`${table.registrationStatus} IS NULL OR ${table.registrationStatus} IN ('active', 'candidate', 'retired', 'orphaned')` + ), + registrationGenerationCheck: check( + 'webhook_registration_generation_check', + sql`${table.registrationGeneration} IS NULL OR ${table.registrationGeneration} >= 0` + ), } } ) +/** + * Owns a normalized path independently from registration generations. + */ +export const webhookPathClaim = pgTable( + 'webhook_path_claim', + { + path: text('path').primaryKey(), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + generation: integer('generation').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + workflowIdx: index('webhook_path_claim_workflow_idx').on(table.workflowId), + generationCheck: check('webhook_path_claim_generation_check', sql`${table.generation} >= 0`), + }) +) + /** * Cooldown state for Sim workspace-event trigger subscriptions. * @@ -1711,6 +1761,16 @@ export const workspaceFiles = pgTable( }), context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution' chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }), + /** + * Logical id of the copilot message this file was born in (the user message the + * upload was attached to). Plain text with no FK: message ids are only unique per + * chat — the same id legitimately exists in the source chat and every fork of it, + * which is what lets a fork's "copy files at-or-before this message" cut match rows + * in both. NULL means "birth unknown / not tracked": rows predating this column and + * contexts that don't stamp it. Nulled together with chatId when a file is + * materialized to the workspace. + */ + messageId: text('message_id'), originalName: text('original_name').notNull(), /** * Collision-disambiguated name exposed to the copilot VFS as `uploads/`. @@ -2645,6 +2705,82 @@ export const workflowDeploymentVersion = pgTable( }) ) +/** + * Tracks mutable deployment attempts separately from immutable version snapshots. + */ +export const workflowDeploymentOperation = pgTable( + 'workflow_deployment_operation', + { + id: text('id').primaryKey(), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + deploymentVersionId: text('deployment_version_id') + .notNull() + .references(() => workflowDeploymentVersion.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + previousActiveVersionId: text('previous_active_version_id').references( + () => workflowDeploymentVersion.id, + { onDelete: 'set null' } + ), + action: text('action').notNull(), + protocolVersion: integer('protocol_version').notNull(), + generation: integer('generation').notNull(), + status: text('status').notNull().default('preparing'), + componentReadiness: jsonb('component_readiness') + .$type>() + .notNull() + .default(sql`'{}'::jsonb`), + errorCode: text('error_code'), + errorMessage: text('error_message'), + idempotencyKey: text('idempotency_key'), + requestHash: text('request_hash').notNull(), + actorId: text('actor_id').notNull(), + completedAt: timestamp('completed_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + workflowGenerationUnique: uniqueIndex( + 'workflow_deployment_operation_workflow_generation_unique' + ).on(table.workflowId, table.generation), + workflowIdempotencyUnique: uniqueIndex( + 'workflow_deployment_operation_workflow_idempotency_unique' + ) + .on(table.workflowId, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`), + workflowInFlightUnique: uniqueIndex('workflow_deployment_operation_workflow_in_flight_unique') + .on(table.workflowId) + .where(sql`${table.status} IN ('preparing', 'activating')`), + workflowStatusIdx: index('workflow_deployment_operation_workflow_status_idx').on( + table.workflowId, + table.status + ), + deploymentVersionIdx: index('workflow_deployment_operation_deployment_version_idx').on( + table.deploymentVersionId + ), + workflowVersionGenerationIdx: index( + 'workflow_deployment_operation_workflow_version_generation_idx' + ).on(table.workflowId, table.deploymentVersionId, table.generation.desc()), + actionCheck: check( + 'workflow_deployment_operation_action_check', + sql`${table.action} IN ('deploy', 'activate')` + ), + statusCheck: check( + 'workflow_deployment_operation_status_check', + sql`${table.status} IN ('preparing', 'activating', 'active', 'failed', 'superseded')` + ), + generationCheck: check( + 'workflow_deployment_operation_generation_check', + sql`${table.generation} > 0` + ), + protocolVersionCheck: check( + 'workflow_deployment_operation_protocol_version_check', + sql`${table.protocolVersion} > 0` + ), + }) +) + // Idempotency keys for preventing duplicate processing across all webhooks and triggers export const idempotencyKey = pgTable( 'idempotency_key', diff --git a/packages/db/workspace-storage-accounting-migration.test.ts b/packages/db/workspace-storage-accounting-migration.test.ts deleted file mode 100644 index 2b86c008d9d..00000000000 --- a/packages/db/workspace-storage-accounting-migration.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @vitest-environment node - */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const migration = readFileSync( - new URL('./migrations/0260_unknown_sinister_six.sql', import.meta.url), - 'utf8' -) - -function splitStatements(sql: string): string[] { - return sql - .split('--> statement-breakpoint') - .map((statement) => statement.replace(/^\s*--.*$/gm, '').trim()) - .filter(Boolean) -} - -describe('workspace storage accounting migration', () => { - it('keeps every statement idempotent so an interrupted run replays from the top', () => { - expect(splitStatements(migration)).toEqual([ - 'ALTER TABLE "paused_executions" ADD COLUMN IF NOT EXISTS "automatic_resume_retry_count" integer DEFAULT 0 NOT NULL;', - 'ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "storage_used_bytes" bigint DEFAULT 0 NOT NULL;', - 'ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "organization_assigned_at" timestamp;', - 'ALTER TABLE "workspace" DROP CONSTRAINT IF EXISTS "workspace_storage_used_bytes_non_negative";', - 'ALTER TABLE "workspace" ADD CONSTRAINT "workspace_storage_used_bytes_non_negative" CHECK ("workspace"."storage_used_bytes" >= 0) NOT VALID;', - 'COMMIT;', - 'SET lock_timeout = 0;', - 'DROP INDEX CONCURRENTLY IF EXISTS "copilot_messages_user_created_at_idx";', - `CREATE INDEX CONCURRENTLY IF NOT EXISTS "copilot_messages_user_created_at_idx" ON "copilot_messages" USING btree ("created_at","chat_id","message_id") WHERE "copilot_messages"."role" = 'user' AND "copilot_messages"."deleted_at" IS NULL;`, - 'DROP INDEX CONCURRENTLY IF EXISTS "outbox_event_type_created_idx";', - 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "outbox_event_type_created_idx" ON "outbox_event" USING btree ("event_type","created_at");', - 'DROP INDEX CONCURRENTLY IF EXISTS "workflow_execution_logs_completed_ended_at_idx";', - `CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_execution_logs_completed_ended_at_idx" ON "workflow_execution_logs" USING btree ("ended_at","workspace_id","execution_id") WHERE "workflow_execution_logs"."status" = 'completed' AND "workflow_execution_logs"."level" = 'info' AND "workflow_execution_logs"."ended_at" IS NOT NULL;`, - `SET lock_timeout = '5s';`, - ]) - }) - - it('adds the storage ledger column with a validated-later non-negative guard', () => { - expect(migration).toContain( - 'ADD COLUMN IF NOT EXISTS "storage_used_bytes" bigint DEFAULT 0 NOT NULL' - ) - expect(migration).toContain('CHECK ("workspace"."storage_used_bytes" >= 0) NOT VALID') - expect(migration).not.toContain('CREATE TABLE "workspace_storage_usage"') - }) - - it('commits transactional DDL before every concurrent index build', () => { - const statements = splitStatements(migration) - const commitIndex = statements.indexOf('COMMIT;') - expect(commitIndex).toBeGreaterThan(-1) - for (const [index, statement] of statements.entries()) { - if (statement.includes('CONCURRENTLY')) { - expect(index).toBeGreaterThan(commitIndex) - } - } - expect(migration).not.toMatch(/(? { - expect(migration).not.toContain('CREATE TRIGGER') - expect(migration).not.toContain('RETURNS trigger') - expect(migration).not.toContain('transfer_workspace_storage_on_payer_change') - expect(migration).not.toContain('subtract_workspace_storage_on_delete') - }) - - it('journals the consolidated migration as the latest entry', () => { - const journal = JSON.parse( - readFileSync(new URL('./migrations/meta/_journal.json', import.meta.url), 'utf8') - ) as { entries: Array<{ idx: number; tag: string }> } - const lastEntry = journal.entries[journal.entries.length - 1] - expect(lastEntry).toMatchObject({ idx: 260, tag: '0260_unknown_sinister_six' }) - }) -}) diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index fedc7d65fec..b9b3d37165c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -7,7 +7,7 @@ export { CalendarDayCell, type CalendarDayCellProps, } from './calendar/calendar-day-cell' -export { Checkbox } from './checkbox/checkbox' +export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox' export { Chip, ChipLink, diff --git a/packages/emcn/src/components/tag-input/tag-input.tsx b/packages/emcn/src/components/tag-input/tag-input.tsx index 7df84dbba29..36f6de7f1d6 100644 --- a/packages/emcn/src/components/tag-input/tag-input.tsx +++ b/packages/emcn/src/components/tag-input/tag-input.tsx @@ -50,8 +50,10 @@ import { Tooltip } from '../tooltip/tooltip' * * @remarks * - `default` matches the standard Input component styling for consistent height. + * Like `ChipInput`, it shows no focus ring — the surface stays calm and the + * caret carries focus. * - `block` matches the multi-row "Description" textarea pattern: larger radius, - * top-aligned items, taller min-height, and no focus ring — for use inside + * top-aligned items, and taller min-height — for use inside * form sections where the tag input visually pairs with textarea fields. * Uses `content-start` so wrapped flex lines pack tightly at `h-5` (20px) row * pitch instead of being stretched by the `min-h-[112px]` floor; unused @@ -64,7 +66,7 @@ const tagInputVariants = cva( variants: { variant: { default: - 'items-center rounded-sm py-1.5 focus-within:outline-none focus-within:ring-1 focus-within:ring-[var(--brand-accent)] dark:bg-[var(--surface-5)]', + 'items-center rounded-sm py-1.5 focus-within:outline-none dark:bg-[var(--surface-5)]', block: 'min-h-[112px] content-start items-start rounded-lg py-2 focus-within:outline-none dark:bg-[var(--surface-4)]', }, diff --git a/packages/emcn/src/icons/chevron-left.tsx b/packages/emcn/src/icons/chevron-left.tsx new file mode 100644 index 00000000000..f79b8280e7f --- /dev/null +++ b/packages/emcn/src/icons/chevron-left.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronLeft icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronLeft(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx new file mode 100644 index 00000000000..2f2a591ae87 --- /dev/null +++ b/packages/emcn/src/icons/chevron-right.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronRight icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronRight(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 6cf6d1bbf20..5bbd98df1b5 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -14,6 +14,8 @@ export { Bug } from './bug' export { Calendar } from './calendar' export { Check } from './check' export { ChevronDown } from './chevron-down' +export { ChevronLeft } from './chevron-left' +export { ChevronRight } from './chevron-right' export { CircleAlert } from './circle-alert' export { CircleCheck } from './circle-check' export { CircleInfo } from './circle-info' @@ -86,6 +88,7 @@ export { ShieldCheck } from './shield-check' export { Shuffle } from './shuffle' export { Sim } from './sim' export { Slash } from './slash' +export { Split } from './split' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' diff --git a/packages/emcn/src/icons/split.tsx b/packages/emcn/src/icons/split.tsx new file mode 100644 index 00000000000..aac39588a5c --- /dev/null +++ b/packages/emcn/src/icons/split.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * Split icon component - one path branching into two + * @param props - SVG properties including className, fill, etc. + */ +export function Split(props: SVGProps) { + return ( + + ) +} diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts index 9a9a18b498d..2d9cd96442d 100644 --- a/packages/security/src/hash.ts +++ b/packages/security/src/hash.ts @@ -1,10 +1,17 @@ import { createHash } from 'node:crypto' /** - * Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for - * indexed lookup of sensitive values (e.g. API key hash columns) where the + * Deterministic SHA-256 digest, hex-encoded. Strings hash as UTF-8; binary + * content passes as a Uint8Array/Buffer. Use for indexed lookup of sensitive + * values (e.g. API key hash columns) and content-integrity receipts where the * caller only needs to verify equality without ever reversing the hash. */ -export function sha256Hex(input: string): string { - return createHash('sha256').update(input, 'utf8').digest('hex') +export function sha256Hex(input: string | Uint8Array): string { + const hash = createHash('sha256') + if (typeof input === 'string') { + hash.update(input, 'utf8') + } else { + hash.update(input) + } + return hash.digest('hex') } diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 6bf30bbc24d..87dccc0f01c 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -14,6 +14,7 @@ import { vi } from 'vitest' */ export const auditMockFns = { mockRecordAudit: vi.fn(), + mockRecordAuditBatch: vi.fn(), } /** @@ -26,6 +27,7 @@ export const auditMockFns = { */ export const auditMock = { recordAudit: auditMockFns.mockRecordAudit, + recordAuditBatch: auditMockFns.mockRecordAuditBatch, AuditAction: { API_KEY_CREATED: 'api_key.created', API_KEY_UPDATED: 'api_key.updated', @@ -141,6 +143,8 @@ export const auditMock = { WORKFLOW_DUPLICATED: 'workflow.duplicated', WORKFLOW_LOCKED: 'workflow.locked', WORKFLOW_UNLOCKED: 'workflow.unlocked', + WORKFLOW_FORK_SYNC_EXCLUDED: 'workflow.fork_sync_excluded', + WORKFLOW_FORK_SYNC_INCLUDED: 'workflow.fork_sync_included', WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index f3470bf55a5..8bd29692bd7 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -265,6 +265,7 @@ export const schemaMock = { id: 'id', workflowId: 'workflowId', deploymentVersionId: 'deploymentVersionId', + deploymentOperationId: 'deploymentOperationId', blockId: 'blockId', cronExpression: 'cronExpression', nextRunAt: 'nextRunAt', @@ -310,6 +311,10 @@ export const schemaMock = { id: 'id', workflowId: 'workflowId', deploymentVersionId: 'deploymentVersionId', + registrationStatus: 'registrationStatus', + registrationGeneration: 'registrationGeneration', + configFingerprint: 'configFingerprint', + preparedAt: 'preparedAt', blockId: 'blockId', path: 'path', provider: 'provider', @@ -321,6 +326,13 @@ export const schemaMock = { createdAt: 'createdAt', updatedAt: 'updatedAt', }, + webhookPathClaim: { + path: 'path', + workflowId: 'workflowId', + generation: 'generation', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, simTriggerState: { workflowId: 'workflowId', blockId: 'blockId', @@ -821,6 +833,26 @@ export const schemaMock = { createdAt: 'createdAt', createdBy: 'createdBy', }, + workflowDeploymentOperation: { + id: 'id', + workflowId: 'workflowId', + deploymentVersionId: 'deploymentVersionId', + version: 'version', + previousActiveVersionId: 'previousActiveVersionId', + action: 'action', + protocolVersion: 'protocolVersion', + generation: 'generation', + status: 'status', + componentReadiness: 'componentReadiness', + errorCode: 'errorCode', + errorMessage: 'errorMessage', + idempotencyKey: 'idempotencyKey', + requestHash: 'requestHash', + actorId: 'actorId', + completedAt: 'completedAt', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, idempotencyKey: { key: 'key', result: 'result', diff --git a/packages/testing/src/mocks/workflows-orchestration.mock.ts b/packages/testing/src/mocks/workflows-orchestration.mock.ts index f2bc234a48d..2290d8f4523 100644 --- a/packages/testing/src/mocks/workflows-orchestration.mock.ts +++ b/packages/testing/src/mocks/workflows-orchestration.mock.ts @@ -18,6 +18,7 @@ export const workflowsOrchestrationMockFns = { mockPerformChatDeploy: vi.fn(), mockPerformChatUndeploy: vi.fn(), mockNotifySocketDeploymentChanged: vi.fn(), + mockGetWorkflowDeploymentSummary: vi.fn(), mockPerformActivateVersion: vi.fn(), mockPerformFullDeploy: vi.fn(), mockPerformFullUndeploy: vi.fn(), @@ -42,6 +43,7 @@ export const workflowsOrchestrationMock = { performChatDeploy: workflowsOrchestrationMockFns.mockPerformChatDeploy, performChatUndeploy: workflowsOrchestrationMockFns.mockPerformChatUndeploy, notifySocketDeploymentChanged: workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged, + getWorkflowDeploymentSummary: workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary, performActivateVersion: workflowsOrchestrationMockFns.mockPerformActivateVersion, performFullDeploy: workflowsOrchestrationMockFns.mockPerformFullDeploy, performFullUndeploy: workflowsOrchestrationMockFns.mockPerformFullUndeploy, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 61afe1084db..449309328c4 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 958, - zodRoutes: 958, + totalRoutes: 963, + zodRoutes: 963, nonZodRoutes: 0, } as const diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 5531b797771..2ae646cd891 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -40,6 +40,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([ 'a2a', 'google-service-account', 'atlassian-service-account', + 'clickup-service-account', 'hubspot-setup', ]) @@ -659,7 +660,7 @@ function extractOAuthServiceId(blockContent: string): string | undefined { * Extract the list of trigger IDs from the block's `triggers.available` array. * Handles blocks that declare `triggers: { enabled: true, available: [...] }`. */ -function extractTriggersAvailable(blockContent: string): string[] { +function extractTriggersAvailable(blockContent: string, fileContent?: string): string[] { const triggersMatch = /\btriggers\s*:\s*\{/.exec(blockContent) if (!triggersMatch) return [] @@ -678,10 +679,26 @@ function extractTriggersAvailable(blockContent: string): string[] { if (arrayEnd === -1) return [] const arrayContent = triggersContent.substring(arrayStart + 1, arrayEnd - 1) + // Blocks like emailbison declare `available: [...LOCAL_TRIGGER_IDS]`; + // resolve same-file const spreads to their literal entries so those + // triggers are not silently dropped from the generated data. + let resolvedContent = arrayContent + const constSource = fileContent ?? blockContent + const spreadRegex = /\.\.\.(\w+)/g + let spreadMatch: RegExpExecArray | null + while ((spreadMatch = spreadRegex.exec(arrayContent)) !== null) { + const constMatch = new RegExp(`const\\s+${spreadMatch[1]}\\s*=\\s*\\[`).exec(constSource) + if (!constMatch) continue + const constStart = constMatch.index + constMatch[0].length - 1 + const constEnd = findMatchingClose(constSource, constStart, '[', ']') + if (constEnd === -1) continue + resolvedContent += constSource.substring(constStart + 1, constEnd - 1) + } + const ids: string[] = [] const idRegex = /['"]([^'"]+)['"]/g let m - while ((m = idRegex.exec(arrayContent)) !== null) { + while ((m = idRegex.exec(resolvedContent)) !== null) { ids.push(m[1]) } return ids @@ -725,6 +742,10 @@ async function buildTriggerRegistry(): Promise> { const nameMatch = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(segment) const descMatch = /\bdescription\s*:\s*['"]([^'"]+)['"]/.exec(segment) + // Deprecated triggers stay registered for existing workflows but are + // excluded from generated documentation. + if (/\bdeprecated\s*:\s*true/.test(segment)) continue + if (idMatch && nameMatch) { registry.set(idMatch[1], { id: idMatch[1], @@ -1119,7 +1140,7 @@ function extractBlockConfigFromContent( } const operations = extractOperationsFromContent(blockContent) - const triggerIds = extractTriggersAvailable(blockContent) + const triggerIds = extractTriggersAvailable(blockContent, fileContent) const docsLink = extractStringPropertyFromContent(blockContent, 'docsLink', true) || baseConfig?.docsLink || @@ -3596,6 +3617,10 @@ async function buildFullTriggerRegistry(): Promise> if (!idMatch || !nameMatch || !providerMatch) continue + // Deprecated triggers stay registered for existing workflows but are + // excluded from generated documentation. + if (/\bdeprecated\s*:\s*true/.test(segment)) continue + const polling = /\bpolling\s*:\s*true/.test(segment) registry.set(idMatch[1], { diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 285743741a6..67f7727f889 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -91,7 +91,7 @@ function renderRuntimeSchemaModule(catalog: { tools: Record[] } 'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined' const resultSchema = 'resultSchema' in tool ? JSON.stringify(tool.resultSchema ?? null, null, 2) : 'undefined' - lines.push(` [${id}]: {`) + lines.push(` ${id}: {`) lines.push( ` parameters: ${parameters === 'null' ? 'undefined' : parameters.replace(/\n/g, '\n ')},` )